Initial top-reservation app: bookings, objects catalog, statistics, PWA
This commit is contained in:
commit
14a849bfb9
62 files changed
+12427
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1788002528986,
|
||||
"tag": "0000_flippant_the_enforcers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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] }),
|
||||
}));
|
||||
Reference in new issue
Block a user