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

+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] }),
}));