Replace hardcoded event details and ticket types with dynamic DB records Add booking-config API endpoint to serve active event settings
690 lines
16 KiB
TypeScript
690 lines
16 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
|
|
import { DEFAULT_USER_PASSWORD } from '~~/shared/auth'
|
|
|
|
import { randomToken } from './base64url'
|
|
import { hashPassword } from './password'
|
|
import { getSqlClient } from './postgres'
|
|
|
|
let databaseReadyPromise: Promise<void> | null = null
|
|
|
|
export async function ensureDatabaseReady() {
|
|
if (!databaseReadyPromise) {
|
|
databaseReadyPromise = initializeDatabase()
|
|
}
|
|
|
|
return databaseReadyPromise
|
|
}
|
|
|
|
async function initializeDatabase() {
|
|
const sql = getSqlClient()
|
|
|
|
await sql`
|
|
create table if not exists users (
|
|
id text primary key,
|
|
username text not null unique,
|
|
full_name text not null,
|
|
phone_number text,
|
|
role text not null check (role in ('super_admin', 'staff')),
|
|
password_hash text not null,
|
|
must_change_password boolean not null default true,
|
|
is_active boolean not null default true,
|
|
created_by text references users(id) on delete set null,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
last_login_at timestamptz
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
alter table users
|
|
add column if not exists phone_number text
|
|
`
|
|
|
|
await sql`
|
|
create table if not exists user_passkeys (
|
|
id text primary key,
|
|
user_id text not null references users(id) on delete cascade,
|
|
credential_id text not null unique,
|
|
public_key text not null,
|
|
counter bigint not null default 0,
|
|
device_type text not null check (device_type in ('singleDevice', 'multiDevice')),
|
|
backed_up boolean not null default false,
|
|
transports jsonb not null default '[]'::jsonb,
|
|
label text not null,
|
|
created_at timestamptz not null default now(),
|
|
last_used_at timestamptz
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
create index if not exists user_passkeys_user_id_idx
|
|
on user_passkeys (user_id)
|
|
`
|
|
|
|
await sql`
|
|
create table if not exists dinner_events (
|
|
id text primary key,
|
|
title text not null,
|
|
date_label text not null,
|
|
time_label text not null,
|
|
venue text not null,
|
|
is_active boolean not null default false,
|
|
sort_order integer not null default 0,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
create unique index if not exists dinner_events_single_active_idx
|
|
on dinner_events (is_active)
|
|
where is_active = true
|
|
`
|
|
|
|
await sql`
|
|
insert into dinner_events (
|
|
id,
|
|
title,
|
|
date_label,
|
|
time_label,
|
|
venue,
|
|
is_active,
|
|
sort_order
|
|
)
|
|
values (
|
|
'dap-johor-60',
|
|
'DAP JOHOR 60th Anniversary Celebration',
|
|
'Saturday, 30 May 2026',
|
|
'6:30 PM',
|
|
'Yong Peng''s Chee Ann Kor',
|
|
true,
|
|
1
|
|
)
|
|
on conflict (id) do nothing
|
|
`
|
|
|
|
await sql`
|
|
update dinner_events
|
|
set
|
|
is_active = true,
|
|
updated_at = now()
|
|
where id = 'dap-johor-60'
|
|
and not exists (
|
|
select 1
|
|
from dinner_events
|
|
where is_active = true
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
create table if not exists booking_modes (
|
|
id text primary key,
|
|
event_id text not null references dinner_events(id) on delete cascade,
|
|
code text not null,
|
|
label text not null,
|
|
quantity_label text not null,
|
|
seats_per_unit integer not null check (seats_per_unit >= 1),
|
|
is_active boolean not null default true,
|
|
sort_order integer not null default 0,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
unique (event_id, code)
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
insert into booking_modes (
|
|
id,
|
|
event_id,
|
|
code,
|
|
label,
|
|
quantity_label,
|
|
seats_per_unit,
|
|
is_active,
|
|
sort_order
|
|
)
|
|
values
|
|
(
|
|
'dap-johor-60-table',
|
|
'dap-johor-60',
|
|
'table',
|
|
'Table (10 seats)',
|
|
'Number of Tables',
|
|
10,
|
|
true,
|
|
1
|
|
),
|
|
(
|
|
'dap-johor-60-seat',
|
|
'dap-johor-60',
|
|
'seat',
|
|
'Seat',
|
|
'Number of Seats',
|
|
1,
|
|
true,
|
|
2
|
|
)
|
|
on conflict (event_id, code) do nothing
|
|
`
|
|
|
|
await sql`
|
|
create table if not exists ticket_types (
|
|
id text primary key,
|
|
event_id text not null references dinner_events(id) on delete cascade,
|
|
code text not null,
|
|
label text not null,
|
|
description text not null,
|
|
price integer not null check (price >= 0),
|
|
is_active boolean not null default true,
|
|
sort_order integer not null default 0,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
unique (event_id, code)
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
insert into ticket_types (
|
|
id,
|
|
event_id,
|
|
code,
|
|
label,
|
|
description,
|
|
price,
|
|
is_active,
|
|
sort_order
|
|
)
|
|
values
|
|
(
|
|
'dap-johor-60-vip',
|
|
'dap-johor-60',
|
|
'vip',
|
|
'VIP',
|
|
'RM150 / seat',
|
|
150,
|
|
true,
|
|
1
|
|
),
|
|
(
|
|
'dap-johor-60-supporter',
|
|
'dap-johor-60',
|
|
'supporter',
|
|
'Supporter',
|
|
'RM60 / seat',
|
|
60,
|
|
true,
|
|
2
|
|
)
|
|
on conflict (event_id, code) do nothing
|
|
`
|
|
|
|
await sql`
|
|
create table if not exists booking_statuses (
|
|
code text primary key,
|
|
label text not null,
|
|
sort_order integer not null default 0,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
insert into booking_statuses (
|
|
code,
|
|
label,
|
|
sort_order
|
|
)
|
|
values
|
|
('pending', 'Pending PIC confirmation', 1),
|
|
('confirmed', 'Confirmed', 2)
|
|
on conflict (code) do nothing
|
|
`
|
|
|
|
await sql`
|
|
create table if not exists bookings (
|
|
id text primary key,
|
|
confirmation_token text not null unique,
|
|
receipt_token text not null unique,
|
|
event_id text references dinner_events(id) on delete restrict,
|
|
customer_name text not null,
|
|
customer_phone text not null,
|
|
booking_mode_id text references booking_modes(id) on delete restrict,
|
|
booking_mode text not null,
|
|
quantity integer not null check (quantity >= 1),
|
|
seat_count integer not null check (seat_count >= 1),
|
|
ticket_type_id text references ticket_types(id) on delete restrict,
|
|
ticket_type text not null,
|
|
unit_price integer not null check (unit_price >= 0),
|
|
total_price integer not null check (total_price >= 0),
|
|
person_in_charge_id text not null references users(id) on delete restrict,
|
|
person_in_charge_name text not null,
|
|
person_in_charge_phone_number text not null,
|
|
status text not null default 'pending',
|
|
confirmed_at timestamptz,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now()
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
add column if not exists receipt_token text
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
add column if not exists event_id text
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
add column if not exists booking_mode_id text
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
add column if not exists ticket_type_id text
|
|
`
|
|
|
|
await sql`
|
|
create unique index if not exists bookings_receipt_token_idx
|
|
on bookings (receipt_token)
|
|
`
|
|
|
|
await sql`
|
|
create index if not exists bookings_event_id_idx
|
|
on bookings (event_id)
|
|
`
|
|
|
|
await sql`
|
|
create index if not exists bookings_booking_mode_id_idx
|
|
on bookings (booking_mode_id)
|
|
`
|
|
|
|
await sql`
|
|
create index if not exists bookings_ticket_type_id_idx
|
|
on bookings (ticket_type_id)
|
|
`
|
|
|
|
await sql`
|
|
create table if not exists booking_seats (
|
|
id text primary key,
|
|
booking_id text not null references bookings(id) on delete cascade,
|
|
seat_number integer not null check (seat_number >= 1),
|
|
seat_token text not null unique,
|
|
recipient_name text,
|
|
recipient_phone text,
|
|
shared_at timestamptz,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
unique (booking_id, seat_number)
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
create index if not exists booking_seats_booking_id_idx
|
|
on booking_seats (booking_id)
|
|
`
|
|
|
|
await sql`
|
|
create table if not exists booking_settings (
|
|
id text primary key,
|
|
event_id text references dinner_events(id) on delete cascade,
|
|
total_tables integer,
|
|
total_seats integer,
|
|
updated_at timestamptz not null default now()
|
|
)
|
|
`
|
|
|
|
await sql`
|
|
alter table booking_settings
|
|
add column if not exists event_id text
|
|
`
|
|
|
|
await sql`
|
|
alter table booking_settings
|
|
add column if not exists total_seats integer
|
|
`
|
|
|
|
await sql`
|
|
insert into booking_settings (id, event_id)
|
|
values ('default', 'dap-johor-60')
|
|
on conflict (id) do update
|
|
set event_id = coalesce(booking_settings.event_id, excluded.event_id)
|
|
`
|
|
|
|
const bookingsMissingReceiptTokens = await sql<{ id: string }[]>`
|
|
select id
|
|
from bookings
|
|
where receipt_token is null or receipt_token = ''
|
|
`
|
|
|
|
for (const booking of bookingsMissingReceiptTokens) {
|
|
await sql`
|
|
update bookings
|
|
set
|
|
receipt_token = ${randomToken(24)},
|
|
updated_at = now()
|
|
where id = ${booking.id}
|
|
`
|
|
}
|
|
|
|
await sql`
|
|
alter table bookings
|
|
drop constraint if exists bookings_booking_mode_check
|
|
`
|
|
|
|
await sql`
|
|
update bookings
|
|
set
|
|
booking_mode = 'seat',
|
|
updated_at = now()
|
|
where booking_mode = 'pax'
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
drop constraint if exists bookings_booking_mode_check
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
drop constraint if exists bookings_ticket_type_check
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
drop constraint if exists bookings_status_check
|
|
`
|
|
|
|
const [activeEvent] = await sql<{ id: string }[]>`
|
|
select id
|
|
from dinner_events
|
|
where is_active = true
|
|
order by sort_order asc, created_at asc
|
|
limit 1
|
|
`
|
|
|
|
if (activeEvent) {
|
|
await sql`
|
|
update booking_settings
|
|
set
|
|
event_id = ${activeEvent.id},
|
|
updated_at = now()
|
|
where event_id is null
|
|
`
|
|
|
|
await sql`
|
|
update bookings
|
|
set
|
|
event_id = ${activeEvent.id},
|
|
updated_at = now()
|
|
where event_id is null
|
|
`
|
|
|
|
await sql`
|
|
update bookings
|
|
set
|
|
booking_mode_id = booking_modes.id,
|
|
updated_at = now()
|
|
from booking_modes
|
|
where bookings.booking_mode_id is null
|
|
and booking_modes.event_id = bookings.event_id
|
|
and booking_modes.code = bookings.booking_mode
|
|
`
|
|
|
|
await sql`
|
|
update bookings
|
|
set
|
|
ticket_type_id = ticket_types.id,
|
|
updated_at = now()
|
|
from ticket_types
|
|
where bookings.ticket_type_id is null
|
|
and ticket_types.event_id = bookings.event_id
|
|
and ticket_types.code = bookings.ticket_type
|
|
`
|
|
|
|
const [fallbackBookingMode] = await sql<{ id: string, code: string }[]>`
|
|
select id, code
|
|
from booking_modes
|
|
where event_id = ${activeEvent.id}
|
|
and is_active = true
|
|
order by sort_order asc, created_at asc
|
|
limit 1
|
|
`
|
|
|
|
if (fallbackBookingMode) {
|
|
await sql`
|
|
update bookings
|
|
set
|
|
booking_mode_id = ${fallbackBookingMode.id},
|
|
booking_mode = ${fallbackBookingMode.code},
|
|
updated_at = now()
|
|
where booking_mode_id is null
|
|
`
|
|
}
|
|
|
|
const [fallbackTicketType] = await sql<{ id: string, code: string }[]>`
|
|
select id, code
|
|
from ticket_types
|
|
where event_id = ${activeEvent.id}
|
|
and is_active = true
|
|
order by sort_order asc, created_at asc
|
|
limit 1
|
|
`
|
|
|
|
if (fallbackTicketType) {
|
|
await sql`
|
|
update bookings
|
|
set
|
|
ticket_type_id = ${fallbackTicketType.id},
|
|
ticket_type = ${fallbackTicketType.code},
|
|
updated_at = now()
|
|
where ticket_type_id is null
|
|
`
|
|
}
|
|
}
|
|
|
|
await sql`
|
|
create unique index if not exists booking_settings_event_id_idx
|
|
on booking_settings (event_id)
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
alter column person_in_charge_name drop not null
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
alter column person_in_charge_phone_number drop not null
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
alter column event_id set not null
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
alter column booking_mode_id set not null
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
alter column ticket_type_id set not null
|
|
`
|
|
|
|
await sql`
|
|
alter table booking_settings
|
|
alter column event_id set not null
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
drop constraint if exists bookings_event_id_fkey
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
add constraint bookings_event_id_fkey
|
|
foreign key (event_id) references dinner_events(id) on delete restrict
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
drop constraint if exists bookings_booking_mode_id_fkey
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
add constraint bookings_booking_mode_id_fkey
|
|
foreign key (booking_mode_id) references booking_modes(id) on delete restrict
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
drop constraint if exists bookings_ticket_type_id_fkey
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
add constraint bookings_ticket_type_id_fkey
|
|
foreign key (ticket_type_id) references ticket_types(id) on delete restrict
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
drop constraint if exists bookings_status_fkey
|
|
`
|
|
|
|
await sql`
|
|
alter table bookings
|
|
add constraint bookings_status_fkey
|
|
foreign key (status) references booking_statuses(code) on delete restrict
|
|
`
|
|
|
|
await sql`
|
|
alter table booking_settings
|
|
drop constraint if exists booking_settings_event_id_fkey
|
|
`
|
|
|
|
await sql`
|
|
alter table booking_settings
|
|
add constraint booking_settings_event_id_fkey
|
|
foreign key (event_id) references dinner_events(id) on delete cascade
|
|
`
|
|
|
|
await sql`
|
|
update booking_settings
|
|
set
|
|
total_seats = total_tables * coalesce((
|
|
select booking_modes.seats_per_unit
|
|
from booking_modes
|
|
where booking_modes.event_id = booking_settings.event_id
|
|
and booking_modes.code = 'table'
|
|
order by booking_modes.sort_order asc
|
|
limit 1
|
|
), 1),
|
|
updated_at = now()
|
|
where total_seats is null
|
|
and total_tables is not null
|
|
`
|
|
|
|
const existingBookings = await sql<{ id: string, seat_count: number | string }[]>`
|
|
select
|
|
id,
|
|
seat_count
|
|
from bookings
|
|
`
|
|
|
|
for (const booking of existingBookings) {
|
|
const seatCount = typeof booking.seat_count === 'number'
|
|
? booking.seat_count
|
|
: Number.parseInt(booking.seat_count, 10)
|
|
|
|
const existingSeatRows = await sql<{ seat_number: number | string }[]>`
|
|
select seat_number
|
|
from booking_seats
|
|
where booking_id = ${booking.id}
|
|
`
|
|
|
|
const existingSeatNumbers = new Set(
|
|
existingSeatRows.map((seat) => typeof seat.seat_number === 'number'
|
|
? seat.seat_number
|
|
: Number.parseInt(seat.seat_number, 10))
|
|
)
|
|
|
|
for (let seatNumber = 1; seatNumber <= seatCount; seatNumber += 1) {
|
|
if (existingSeatNumbers.has(seatNumber)) {
|
|
continue
|
|
}
|
|
|
|
await sql`
|
|
insert into booking_seats (
|
|
id,
|
|
booking_id,
|
|
seat_number,
|
|
seat_token
|
|
)
|
|
values (
|
|
${randomUUID()},
|
|
${booking.id},
|
|
${seatNumber},
|
|
${randomToken(24)}
|
|
)
|
|
on conflict (booking_id, seat_number) do nothing
|
|
`
|
|
}
|
|
}
|
|
|
|
await sql`
|
|
alter table bookings
|
|
alter column receipt_token set not null
|
|
`
|
|
|
|
const [existingSuperAdmin] = await sql<{ id: string }[]>`
|
|
select id
|
|
from users
|
|
where username = 'xiaomai'
|
|
limit 1
|
|
`
|
|
|
|
if (!existingSuperAdmin) {
|
|
const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD)
|
|
|
|
await sql`
|
|
insert into users (
|
|
id,
|
|
username,
|
|
full_name,
|
|
role,
|
|
password_hash,
|
|
must_change_password,
|
|
is_active,
|
|
created_by
|
|
)
|
|
values (
|
|
${randomUUID()},
|
|
'xiaomai',
|
|
'Xiaomai',
|
|
'super_admin',
|
|
${passwordHash},
|
|
true,
|
|
true,
|
|
null
|
|
)
|
|
`
|
|
}
|
|
|
|
await sql`
|
|
update users
|
|
set
|
|
phone_number = '+601157753558',
|
|
updated_at = now()
|
|
where username = 'xiaomai'
|
|
and (phone_number is null or phone_number = '')
|
|
`
|
|
}
|