Initial top-reservation app: bookings, objects catalog, statistics, PWA

This commit is contained in:
ogrechko committed 2026-08-29 11:30:46 +00:00
commit 14a849bfb9
62 files changed
+12427

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { getCurrentSession } from "./session";
/** Guard for API route handlers — returns the session, or a 401 response to return as-is. */
export async function requireSession() {
const session = await getCurrentSession();
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
return { session, response: null };
}
/** Same as requireSession, but also requires role === "admin" — for account/LDAP management. */
export async function requireAdminSession() {
const session = await getCurrentSession();
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
if (session.user.role !== "admin") {
return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { session, response: null };
}
+76
View File
@@ -0,0 +1,76 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sessions, users } from "@/lib/db/schema";
const SESSION_COOKIE = "session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function createSession(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({ tokenHash, userId, expiresAt });
return token;
}
export async function validateSessionToken(token: string) {
const tokenHash = hashToken(token);
const rows = await db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() < Date.now()) {
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
return null;
}
return row;
}
export async function destroySessionToken(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
// Secure by default in production. Set COOKIE_ALLOW_INSECURE=true only
// for temporary testing over plain HTTP (e.g. mid-migration before the
// HTTPS reverse proxy is wired up) — a Secure cookie is silently
// dropped by the browser over HTTP, which looks exactly like "login
// accepts the password but you're bounced straight back to /login".
secure: process.env.NODE_ENV === "production" && process.env.COOKIE_ALLOW_INSECURE !== "true",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getSessionToken(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE)?.value;
}
/** Reads the session cookie and validates it against the DB. Returns null if absent/invalid/expired. */
export async function getCurrentSession() {
const token = await getSessionToken();
if (!token) return null;
return validateSessionToken(token);
}
+79
View File
@@ -0,0 +1,79 @@
import { eq, ne, and, count } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { hashPassword } from "@/lib/auth/password";
export interface UserAccountDTO {
id: string;
name: string;
email: string;
role: "admin" | "staff";
createdAt: number;
}
function toUserDTO(user: typeof users.$inferSelect): UserAccountDTO {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
createdAt: user.createdAt.getTime(),
};
}
export async function listUsers(): Promise<UserAccountDTO[]> {
const rows = await db.query.users.findMany({ orderBy: users.name });
return rows.map(toUserDTO);
}
export async function createUser(params: {
name: string;
email: string;
password: string;
role: "admin" | "staff";
}): Promise<UserAccountDTO> {
const passwordHash = await hashPassword(params.password);
const [user] = await db
.insert(users)
.values({
name: params.name,
email: params.email.toLowerCase().trim(),
passwordHash,
role: params.role,
})
.returning();
return toUserDTO(user);
}
async function adminCount(excludingUserId?: string): Promise<number> {
const where = excludingUserId
? and(eq(users.role, "admin"), ne(users.id, excludingUserId))
: eq(users.role, "admin");
const [row] = await db.select({ n: count() }).from(users).where(where);
return row?.n ?? 0;
}
export async function updateUserRole(id: string, role: "admin" | "staff"): Promise<UserAccountDTO> {
if (role === "staff") {
// Demoting the last admin would lock everyone out of admin-only pages.
const remainingAdmins = await adminCount(id);
const target = await db.query.users.findFirst({ where: eq(users.id, id) });
if (target?.role === "admin" && remainingAdmins === 0) {
throw new Error("Нельзя понизить последнего администратора");
}
}
const [user] = await db.update(users).set({ role }).where(eq(users.id, id)).returning();
return toUserDTO(user);
}
export async function deleteUser(id: string): Promise<void> {
const target = await db.query.users.findFirst({ where: eq(users.id, id) });
if (target?.role === "admin") {
const remainingAdmins = await adminCount(id);
if (remainingAdmins === 0) {
throw new Error("Нельзя удалить последнего администратора");
}
}
await db.delete(users).where(eq(users.id, id));
}
+44
View File
@@ -0,0 +1,44 @@
export interface BookingSummaryInput {
bookingNumber: number;
contactName: string;
phone: string;
objectNames: string[];
date: Date;
checkIn: string;
checkOut: string;
guestCount: number;
cost: number;
prepayment: number;
comment: string | null;
source: string | null;
bookedByName: string;
}
function formatDate(d: Date): string {
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
return `${dd}.${mm}.${d.getFullYear()}`;
}
/**
* Plain-text booking summary — deliberately mirrors the old Telegram bot's
* "Готово! Вот что я забронировал:" message format so staff can paste it
* anywhere they're used to pasting the bot's output.
*/
export function formatBookingSummary(b: BookingSummaryInput): string {
const lines = [
`Номер бронирования: ${b.bookingNumber}`,
`Контактное лицо: ${b.contactName}`,
b.phone,
`Объект: ${b.objectNames.join("/") || "—"}`,
`Дата: ${formatDate(b.date)}`,
`Время заезда/выезда: ${b.checkIn} - ${b.checkOut}`,
`Количество людей: ${b.guestCount}`,
`Стоимость бронирования: ${b.cost}`,
`Предоплата: ${b.prepayment}`,
`Комментарий: ${b.comment?.trim() || "-"}`,
`Откуда пришли: ${b.source?.trim() || "-"}`,
`Кто забронировал: ${b.bookedByName}`,
];
return lines.join("\n");
}
+229
View File
@@ -0,0 +1,229 @@
import { and, eq, gte, lt, inArray, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { bookings, bookingObjects, bookableObjects, users } from "@/lib/db/schema";
export interface BookableObjectDTO {
id: string;
name: string;
active: boolean;
sortOrder: number;
}
export async function listBookableObjects(includeInactive = false): Promise<BookableObjectDTO[]> {
const rows = await db.query.bookableObjects.findMany({
where: includeInactive ? undefined : eq(bookableObjects.active, true),
orderBy: (t, { asc }) => [asc(t.sortOrder), asc(t.name)],
});
return rows;
}
export async function createBookableObject(name: string): Promise<BookableObjectDTO> {
const maxOrder = await db
.select({ max: sql<number>`coalesce(max(${bookableObjects.sortOrder}), 0)` })
.from(bookableObjects);
const [row] = await db
.insert(bookableObjects)
.values({ name, sortOrder: (maxOrder[0]?.max ?? 0) + 1 })
.returning();
return row;
}
export async function setBookableObjectActive(id: string, active: boolean): Promise<void> {
await db.update(bookableObjects).set({ active }).where(eq(bookableObjects.id, id));
}
export async function deleteBookableObject(id: string): Promise<void> {
await db.delete(bookableObjects).where(eq(bookableObjects.id, id));
}
export interface BookingDTO {
id: string;
bookingNumber: number;
contactName: string;
phone: string;
date: Date;
checkIn: string;
checkOut: string;
guestCount: number;
cost: number;
prepayment: number;
comment: string | null;
source: string | null;
status: "confirmed" | "cancelled";
objects: { id: string; name: string }[];
bookedByName: string;
createdAt: Date;
}
async function toBookingDTO(row: typeof bookings.$inferSelect): Promise<BookingDTO> {
const [objRows, bookedBy] = await Promise.all([
db
.select({ id: bookableObjects.id, name: bookableObjects.name })
.from(bookingObjects)
.innerJoin(bookableObjects, eq(bookingObjects.objectId, bookableObjects.id))
.where(eq(bookingObjects.bookingId, row.id)),
db.query.users.findFirst({ where: eq(users.id, row.bookedByUserId), columns: { name: true } }),
]);
return {
id: row.id,
bookingNumber: row.bookingNumber,
contactName: row.contactName,
phone: row.phone,
date: row.date,
checkIn: row.checkIn,
checkOut: row.checkOut,
guestCount: row.guestCount,
cost: row.cost,
prepayment: row.prepayment,
comment: row.comment,
source: row.source,
status: row.status,
objects: objRows,
bookedByName: bookedBy?.name ?? "—",
createdAt: row.createdAt,
};
}
export interface CreateBookingInput {
contactName: string;
phone: string;
objectIds: string[];
date: Date;
checkIn: string;
checkOut: string;
guestCount: number;
cost: number;
prepayment: number;
comment?: string | null;
source?: string | null;
bookedByUserId: string;
}
export async function createBooking(input: CreateBookingInput): Promise<BookingDTO> {
const maxRow = await db.select({ max: sql<number>`coalesce(max(${bookings.bookingNumber}), 0)` }).from(bookings);
const bookingNumber = (maxRow[0]?.max ?? 0) + 1;
const [row] = await db
.insert(bookings)
.values({
bookingNumber,
contactName: input.contactName,
phone: input.phone,
date: input.date,
checkIn: input.checkIn,
checkOut: input.checkOut,
guestCount: input.guestCount,
cost: input.cost,
prepayment: input.prepayment,
comment: input.comment ?? null,
source: input.source ?? null,
bookedByUserId: input.bookedByUserId,
})
.returning();
if (input.objectIds.length > 0) {
await db.insert(bookingObjects).values(input.objectIds.map((objectId) => ({ bookingId: row.id, objectId })));
}
return toBookingDTO(row);
}
export interface UpdateBookingInput {
contactName?: string;
phone?: string;
objectIds?: string[];
date?: Date;
checkIn?: string;
checkOut?: string;
guestCount?: number;
cost?: number;
prepayment?: number;
comment?: string | null;
source?: string | null;
status?: "confirmed" | "cancelled";
}
export async function updateBooking(id: string, input: UpdateBookingInput): Promise<BookingDTO | null> {
const { objectIds, ...rest } = input;
if (Object.keys(rest).length > 0) {
await db
.update(bookings)
.set({ ...rest, updatedAt: new Date() })
.where(eq(bookings.id, id));
}
if (objectIds) {
await db.delete(bookingObjects).where(eq(bookingObjects.bookingId, id));
if (objectIds.length > 0) {
await db.insert(bookingObjects).values(objectIds.map((objectId) => ({ bookingId: id, objectId })));
}
}
const row = await db.query.bookings.findFirst({ where: eq(bookings.id, id) });
if (!row) return null;
return toBookingDTO(row);
}
export async function getBooking(id: string): Promise<BookingDTO | null> {
const row = await db.query.bookings.findFirst({ where: eq(bookings.id, id) });
if (!row) return null;
return toBookingDTO(row);
}
export interface ListBookingsFilter {
from?: Date;
to?: Date;
status?: "confirmed" | "cancelled";
search?: string;
}
export async function listBookings(filter: ListBookingsFilter = {}): Promise<BookingDTO[]> {
const conditions = [];
if (filter.from) conditions.push(gte(bookings.date, filter.from));
if (filter.to) conditions.push(lt(bookings.date, filter.to));
if (filter.status) conditions.push(eq(bookings.status, filter.status));
const rows = await db.query.bookings.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: (t, { asc }) => [asc(t.date)],
});
const filtered = filter.search
? rows.filter(
(r) =>
r.contactName.toLowerCase().includes(filter.search!.toLowerCase()) ||
r.phone.includes(filter.search!),
)
: rows;
return Promise.all(filtered.map(toBookingDTO));
}
/** Monthly stats grouped by bookable object — a booking with N objects contributes to all N counts. */
export async function getMonthlyObjectStats(monthKey: string): Promise<{ name: string; count: number }[]> {
const [y, m] = monthKey.split("-").map(Number);
const from = new Date(Date.UTC(y, m - 1, 1));
const to = new Date(Date.UTC(y, m, 1));
const rows = await db
.select({ name: bookableObjects.name })
.from(bookingObjects)
.innerJoin(bookings, eq(bookingObjects.bookingId, bookings.id))
.innerJoin(bookableObjects, eq(bookingObjects.objectId, bookableObjects.id))
.where(and(gte(bookings.date, from), lt(bookings.date, to), eq(bookings.status, "confirmed")));
const counts = new Map<string, number>();
for (const r of rows) counts.set(r.name, (counts.get(r.name) ?? 0) + 1);
return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
}
export async function listBookableObjectsByIds(ids: string[]) {
if (ids.length === 0) return [];
return db.select().from(bookableObjects).where(inArray(bookableObjects.id, ids));
}
export async function listUpcomingBookings(daysAhead: number, statusConfirmedOnly = true): Promise<BookingDTO[]> {
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const to = new Date(from.getTime() + daysAhead * 24 * 60 * 60 * 1000);
return listBookings({ from, to, status: statusConfirmedOnly ? "confirmed" : undefined });
}
+20
View File
@@ -0,0 +1,20 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import * as schema from "./schema";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
fs.mkdirSync(dataDir, { recursive: true });
const sqlite = new Database(path.join(dataDir, "db.sqlite"));
// busy_timeout MUST be set before journal_mode: switching to WAL itself
// takes a momentary exclusive lock, and if a concurrent worker is mid-switch
// on a fresh db.sqlite, that first statement has no busy_timeout protection
// yet and throws SQLITE_BUSY immediately instead of waiting.
sqlite.pragma("busy_timeout = 30000");
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema });
export type DB = typeof db;
@@ -0,0 +1,56 @@
CREATE TABLE `bookable_objects` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`active` integer DEFAULT true NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `bookable_objects_name_unique` ON `bookable_objects` (`name`);--> statement-breakpoint
CREATE TABLE `booking_objects` (
`booking_id` text NOT NULL,
`object_id` text NOT NULL,
PRIMARY KEY(`booking_id`, `object_id`),
FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`object_id`) REFERENCES `bookable_objects`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE TABLE `bookings` (
`id` text PRIMARY KEY NOT NULL,
`booking_number` integer NOT NULL,
`contact_name` text NOT NULL,
`phone` text NOT NULL,
`date` integer NOT NULL,
`check_in` text NOT NULL,
`check_out` text NOT NULL,
`guest_count` integer NOT NULL,
`cost` integer NOT NULL,
`prepayment` integer DEFAULT 0 NOT NULL,
`comment` text,
`source` text,
`booked_by_user_id` text NOT NULL,
`status` text DEFAULT 'confirmed' NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`booked_by_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE UNIQUE INDEX `bookings_booking_number_unique` ON `bookings` (`booking_number`);--> statement-breakpoint
CREATE TABLE `sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text NOT NULL,
`name` text NOT NULL,
`role` text DEFAULT 'staff' NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
@@ -0,0 +1,396 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1e6cef10-8b62-4e22-9659-2a6797bc15f0",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"bookable_objects": {
"name": "bookable_objects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"bookable_objects_name_unique": {
"name": "bookable_objects_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"booking_objects": {
"name": "booking_objects",
"columns": {
"booking_id": {
"name": "booking_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"object_id": {
"name": "object_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"booking_objects_booking_id_bookings_id_fk": {
"name": "booking_objects_booking_id_bookings_id_fk",
"tableFrom": "booking_objects",
"tableTo": "bookings",
"columnsFrom": [
"booking_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"booking_objects_object_id_bookable_objects_id_fk": {
"name": "booking_objects_object_id_bookable_objects_id_fk",
"tableFrom": "booking_objects",
"tableTo": "bookable_objects",
"columnsFrom": [
"object_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"booking_objects_booking_id_object_id_pk": {
"columns": [
"booking_id",
"object_id"
],
"name": "booking_objects_booking_id_object_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"bookings": {
"name": "bookings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"booking_number": {
"name": "booking_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"contact_name": {
"name": "contact_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"check_in": {
"name": "check_in",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"check_out": {
"name": "check_out",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guest_count": {
"name": "guest_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cost": {
"name": "cost",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prepayment": {
"name": "prepayment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"booked_by_user_id": {
"name": "booked_by_user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'confirmed'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"bookings_booking_number_unique": {
"name": "bookings_booking_number_unique",
"columns": [
"booking_number"
],
"isUnique": true
}
},
"foreignKeys": {
"bookings_booked_by_user_id_users_id_fk": {
"name": "bookings_booked_by_user_id_users_id_fk",
"tableFrom": "bookings",
"tableTo": "users",
"columnsFrom": [
"booked_by_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'staff'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1788002528986,
"tag": "0000_flippant_the_enforcers",
"breakpoints": true
}
]
}
+120
View File
@@ -0,0 +1,120 @@
import { sql, relations } from "drizzle-orm";
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
import crypto from "node:crypto";
const id = () =>
text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
const timestamps = {
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
};
/** Staff accounts — the people who take and manage bookings. */
export const users = sqliteTable("users", {
id: id(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
name: text("name").notNull(),
role: text("role", { enum: ["admin", "staff"] })
.notNull()
.default("staff"),
createdAt: timestamps.createdAt,
});
export const sessions = sqliteTable("sessions", {
// primary key is the SHA-256 hash of the raw session token — the raw
// token only ever lives in the client's httpOnly cookie.
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: timestamps.createdAt,
});
/** The catalog of bookable things — Баня, Афрейм, Дом с ванной, etc. A booking can select several at once (a "combo"). */
export const bookableObjects = sqliteTable("bookable_objects", {
id: id(),
name: text("name").notNull().unique(),
active: integer("active", { mode: "boolean" }).notNull().default(true),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamps.createdAt,
});
/**
* bookingNumber is a plain incrementing integer (not the uuid id) — this is
* the human-facing "Номер бронирования" staff read out loud / write down,
* assigned as max(bookingNumber)+1 inside the same transaction as the
* insert. Starts at 1; this is a fresh replacement for the old Telegram
* bot, not a continuation of its numbering.
*/
export const bookings = sqliteTable("bookings", {
id: id(),
bookingNumber: integer("booking_number").notNull().unique(),
contactName: text("contact_name").notNull(),
phone: text("phone").notNull(),
// Just the day — check-in/check-out are separate free-form "HH:MM" text
// fields below, matching how the bot itself asked for them ("Время
// заезда/выезда", one combined answer like "13:00 - 18:00").
date: integer("date", { mode: "timestamp_ms" }).notNull(),
checkIn: text("check_in").notNull(),
checkOut: text("check_out").notNull(),
guestCount: integer("guest_count").notNull(),
cost: integer("cost").notNull(),
prepayment: integer("prepayment").notNull().default(0),
comment: text("comment"),
// "Откуда пришли" — free-text marketing attribution (e.g. "Группа в ВК"), exactly like the bot.
source: text("source"),
bookedByUserId: text("booked_by_user_id")
.notNull()
.references(() => users.id, { onDelete: "restrict" }),
status: text("status", { enum: ["confirmed", "cancelled"] })
.notNull()
.default("confirmed"),
createdAt: timestamps.createdAt,
updatedAt: timestamps.updatedAt,
});
/** Junction table for the booking <-> bookable-object multi-select. */
export const bookingObjects = sqliteTable(
"booking_objects",
{
bookingId: text("booking_id")
.notNull()
.references(() => bookings.id, { onDelete: "cascade" }),
objectId: text("object_id")
.notNull()
.references(() => bookableObjects.id, { onDelete: "restrict" }),
},
(table) => [primaryKey({ columns: [table.bookingId, table.objectId] })],
);
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
bookings: many(bookings),
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const bookingsRelations = relations(bookings, ({ one, many }) => ({
bookedBy: one(users, { fields: [bookings.bookedByUserId], references: [users.id] }),
objects: many(bookingObjects),
}));
export const bookableObjectsRelations = relations(bookableObjects, ({ many }) => ({
bookings: many(bookingObjects),
}));
export const bookingObjectsRelations = relations(bookingObjects, ({ one }) => ({
booking: one(bookings, { fields: [bookingObjects.bookingId], references: [bookings.id] }),
object: one(bookableObjects, { fields: [bookingObjects.objectId], references: [bookableObjects.id] }),
}));