Add a remark column to the bookings table for management-only notes. Include UI to view and edit remarks directly from the bookings list. Create API endpoint and database queries to support remark updates.
696 lines
16 KiB
TypeScript
696 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,
|
|
remark text,
|
|
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`
|
|
alter table bookings
|
|
add column if not exists remark 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 = '')
|
|
`
|
|
}
|