feat(bookings): implement booking system and confirmation flow
Add database tables and repository for managing bookings Create API endpoints for booking submission and capacity management Update landing page to persist bookings before WhatsApp redirection
This commit is contained in:
21
server/api/bookings.get.ts
Normal file
21
server/api/bookings.get.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { requireAuth } from '../utils/auth'
|
||||
import { getBookingCapacitySettings, getBookingInventorySummary, listBookings } from '../utils/booking-repository'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await requireAuth(event)
|
||||
const [bookings, settings, summary] = await Promise.all([
|
||||
listBookings(
|
||||
auth.user.role === 'super_admin'
|
||||
? undefined
|
||||
: { personInChargeId: auth.user.id }
|
||||
),
|
||||
getBookingCapacitySettings(),
|
||||
getBookingInventorySummary()
|
||||
])
|
||||
|
||||
return {
|
||||
bookings,
|
||||
settings,
|
||||
summary
|
||||
}
|
||||
})
|
||||
26
server/api/bookings/capacity.patch.ts
Normal file
26
server/api/bookings/capacity.patch.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { requireRole } from '../../utils/auth'
|
||||
import { parseBookingCapacityInput } from '../../utils/bookings'
|
||||
import { getBookingInventorySummary, updateBookingCapacitySettings } from '../../utils/booking-repository'
|
||||
import { assertBadRequest } from '../../utils/http'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireRole(event, 'super_admin')
|
||||
|
||||
const body = await readBody<{
|
||||
totalTables?: number | string | null
|
||||
}>(event)
|
||||
|
||||
const input = parseBookingCapacityInput(body)
|
||||
const summary = await getBookingInventorySummary()
|
||||
|
||||
assertBadRequest(
|
||||
input.totalTables === null || (input.totalTables * 10) >= summary.soldCapacitySeats,
|
||||
`Total tables cannot be lower than the currently sold capacity of ${summary.soldTables} tables and ${summary.soldSeats} seats`
|
||||
)
|
||||
|
||||
const settings = await updateBookingCapacitySettings(input)
|
||||
|
||||
return {
|
||||
settings
|
||||
}
|
||||
})
|
||||
56
server/api/public/bookings.post.ts
Normal file
56
server/api/public/bookings.post.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { BookingMode, CreateBookingResponse, TicketType } from '~~/shared/booking'
|
||||
|
||||
import { getTicketCatalogItem, getSeatCount } from '~~/shared/booking'
|
||||
|
||||
import { buildAppUrl } from '../../utils/app-url'
|
||||
import { createBooking } from '../../utils/booking-repository'
|
||||
import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings'
|
||||
import { assertBadRequest } from '../../utils/http'
|
||||
import { getPublicContactById } from '../../utils/user-repository'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<CreateBookingResponse> => {
|
||||
const body = await readBody<{
|
||||
customerName?: string
|
||||
customerPhone?: string
|
||||
bookingMode?: BookingMode
|
||||
quantity?: number
|
||||
ticketType?: TicketType
|
||||
personInChargeId?: string
|
||||
}>(event)
|
||||
|
||||
const input = parseCreateBookingInput(body)
|
||||
const personInCharge = await getPublicContactById(input.personInChargeId)
|
||||
|
||||
assertBadRequest(personInCharge, 'Selected person in charge is not available')
|
||||
|
||||
const ticket = getTicketCatalogItem(input.ticketType)
|
||||
|
||||
assertBadRequest(ticket, 'Ticket category is invalid')
|
||||
|
||||
const seatCount = getSeatCount(input.bookingMode, input.quantity)
|
||||
const totalPrice = seatCount * ticket.price
|
||||
|
||||
const { booking, confirmationToken } = await createBooking({
|
||||
customerName: input.customerName,
|
||||
customerPhone: input.customerPhone,
|
||||
bookingMode: input.bookingMode,
|
||||
quantity: input.quantity,
|
||||
seatCount,
|
||||
ticketType: input.ticketType,
|
||||
unitPrice: ticket.price,
|
||||
totalPrice,
|
||||
personInChargeId: personInCharge.id,
|
||||
personInChargeName: personInCharge.fullName,
|
||||
personInChargePhoneNumber: personInCharge.phoneNumber
|
||||
})
|
||||
|
||||
const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`)
|
||||
const whatsappMessage = buildBookingMessage(booking, confirmationUrl)
|
||||
const whatsappUrl = `https://wa.me/${booking.personInChargePhoneNumber}?text=${encodeURIComponent(whatsappMessage)}`
|
||||
|
||||
return {
|
||||
booking,
|
||||
confirmationUrl,
|
||||
whatsappUrl
|
||||
}
|
||||
})
|
||||
15
server/api/public/bookings/[token].get.ts
Normal file
15
server/api/public/bookings/[token].get.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getRequiredRouteParam, httpError } from '../../../utils/http'
|
||||
import { getBookingByConfirmationToken } from '../../../utils/booking-repository'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||
const booking = await getBookingByConfirmationToken(token)
|
||||
|
||||
if (!booking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
return {
|
||||
booking
|
||||
}
|
||||
})
|
||||
35
server/api/public/bookings/[token]/confirm.post.ts
Normal file
35
server/api/public/bookings/[token]/confirm.post.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { confirmBookingByConfirmationToken, getBookingByConfirmationToken, getBookingInventorySummary } from '../../../../utils/booking-repository'
|
||||
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||
const existingBooking = await getBookingByConfirmationToken(token)
|
||||
|
||||
if (!existingBooking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
if (existingBooking.status === 'confirmed') {
|
||||
return {
|
||||
booking: existingBooking,
|
||||
alreadyConfirmed: true
|
||||
}
|
||||
}
|
||||
|
||||
const summary = await getBookingInventorySummary()
|
||||
|
||||
if (summary.leftCapacitySeats !== null && existingBooking.seatCount > summary.leftCapacitySeats) {
|
||||
httpError(409, 'Not enough capacity left to confirm this booking')
|
||||
}
|
||||
|
||||
const booking = await confirmBookingByConfirmationToken(token)
|
||||
|
||||
if (!booking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
return {
|
||||
booking,
|
||||
alreadyConfirmed: false
|
||||
}
|
||||
})
|
||||
18
server/utils/app-url.ts
Normal file
18
server/utils/app-url.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
import { getRequestURL } from 'h3'
|
||||
|
||||
export function getAppOrigin(event: H3Event) {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (config.public.appUrl) {
|
||||
return new URL(config.public.appUrl).origin
|
||||
}
|
||||
|
||||
const url = getRequestURL(event)
|
||||
return `${url.protocol}//${url.host}`
|
||||
}
|
||||
|
||||
export function buildAppUrl(event: H3Event, path: string) {
|
||||
return new URL(path, getAppOrigin(event)).toString()
|
||||
}
|
||||
322
server/utils/booking-repository.ts
Normal file
322
server/utils/booking-repository.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type {
|
||||
BookingCapacitySettings,
|
||||
BookingInventorySummary,
|
||||
BookingMode,
|
||||
BookingStatus,
|
||||
PublicBooking,
|
||||
TicketType
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { calculateBookingInventorySummary, isBookingStatus } from '~~/shared/booking'
|
||||
|
||||
import { randomToken, toIsoString } from './base64url'
|
||||
import { ensureDatabaseReady } from './db-init'
|
||||
import { getSqlClient } from './postgres'
|
||||
|
||||
type DbBookingRow = {
|
||||
id: string
|
||||
confirmation_token: string
|
||||
customer_name: string
|
||||
customer_phone: string
|
||||
booking_mode: BookingMode
|
||||
quantity: number | string
|
||||
seat_count: number | string
|
||||
ticket_type: TicketType
|
||||
unit_price: number | string
|
||||
total_price: number | string
|
||||
person_in_charge_id: string
|
||||
person_in_charge_name: string
|
||||
person_in_charge_phone_number: string
|
||||
status: BookingStatus | string
|
||||
created_at: Date | string
|
||||
confirmed_at: Date | string | null
|
||||
}
|
||||
|
||||
type DbBookingSettingsRow = {
|
||||
total_tables: number | string | null
|
||||
updated_at: Date | string
|
||||
}
|
||||
|
||||
function parseInteger(value: number | string) {
|
||||
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||
}
|
||||
|
||||
function mapBooking(row: DbBookingRow): PublicBooking {
|
||||
return {
|
||||
id: row.id,
|
||||
confirmationToken: row.confirmation_token,
|
||||
customerName: row.customer_name,
|
||||
customerPhone: row.customer_phone,
|
||||
bookingMode: row.booking_mode,
|
||||
quantity: parseInteger(row.quantity),
|
||||
seatCount: parseInteger(row.seat_count),
|
||||
ticketType: row.ticket_type,
|
||||
unitPrice: parseInteger(row.unit_price),
|
||||
totalPrice: parseInteger(row.total_price),
|
||||
personInChargeId: row.person_in_charge_id,
|
||||
personInChargeName: row.person_in_charge_name,
|
||||
personInChargePhoneNumber: row.person_in_charge_phone_number,
|
||||
status: isBookingStatus(row.status) ? row.status : 'pending',
|
||||
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
||||
confirmedAt: toIsoString(row.confirmed_at)
|
||||
}
|
||||
}
|
||||
|
||||
function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings {
|
||||
if (!row) {
|
||||
return {
|
||||
totalTables: null,
|
||||
totalSeats: null,
|
||||
updatedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalTables: row.total_tables === null ? null : parseInteger(row.total_tables),
|
||||
updatedAt: toIsoString(row.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBooking(input: {
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
bookingMode: BookingMode
|
||||
quantity: number
|
||||
seatCount: number
|
||||
ticketType: TicketType
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
personInChargeId: string
|
||||
personInChargeName: string
|
||||
personInChargePhoneNumber: string
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
const confirmationToken = randomToken(24)
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
insert into bookings (
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status
|
||||
)
|
||||
values (
|
||||
${randomUUID()},
|
||||
${confirmationToken},
|
||||
${input.customerName},
|
||||
${input.customerPhone},
|
||||
${input.bookingMode},
|
||||
${input.quantity},
|
||||
${input.seatCount},
|
||||
${input.ticketType},
|
||||
${input.unitPrice},
|
||||
${input.totalPrice},
|
||||
${input.personInChargeId},
|
||||
${input.personInChargeName},
|
||||
${input.personInChargePhoneNumber},
|
||||
'pending'
|
||||
)
|
||||
returning
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
`
|
||||
|
||||
return {
|
||||
booking: mapBooking(row),
|
||||
confirmationToken
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
from bookings
|
||||
where confirmation_token = ${confirmationToken}
|
||||
limit 1
|
||||
`
|
||||
|
||||
return row ? mapBooking(row) : null
|
||||
}
|
||||
|
||||
export async function listBookings(options?: {
|
||||
personInChargeId?: string
|
||||
}): Promise<PublicBooking[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = options?.personInChargeId
|
||||
? await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
from bookings
|
||||
where person_in_charge_id = ${options.personInChargeId}
|
||||
order by created_at desc
|
||||
`
|
||||
: await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
from bookings
|
||||
order by created_at desc
|
||||
`
|
||||
|
||||
return rows.map(mapBooking)
|
||||
}
|
||||
|
||||
export async function getBookingCapacitySettings(): Promise<BookingCapacitySettings> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingSettingsRow[]>`
|
||||
select
|
||||
total_tables,
|
||||
updated_at
|
||||
from booking_settings
|
||||
where id = 'default'
|
||||
limit 1
|
||||
`
|
||||
|
||||
return mapBookingCapacitySettings(row)
|
||||
}
|
||||
|
||||
export async function updateBookingCapacitySettings(input: {
|
||||
totalTables: number | null
|
||||
}): Promise<BookingCapacitySettings> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingSettingsRow[]>`
|
||||
update booking_settings
|
||||
set
|
||||
total_tables = ${input.totalTables},
|
||||
updated_at = now()
|
||||
where id = 'default'
|
||||
returning
|
||||
total_tables,
|
||||
updated_at
|
||||
`
|
||||
|
||||
return mapBookingCapacitySettings(row)
|
||||
}
|
||||
|
||||
export async function getBookingInventorySummary(): Promise<BookingInventorySummary> {
|
||||
const [bookings, settings] = await Promise.all([
|
||||
listBookings(),
|
||||
getBookingCapacitySettings()
|
||||
])
|
||||
|
||||
return calculateBookingInventorySummary(bookings, settings)
|
||||
}
|
||||
|
||||
export async function confirmBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
update bookings
|
||||
set
|
||||
status = 'confirmed',
|
||||
confirmed_at = now(),
|
||||
updated_at = now()
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and status = 'pending'
|
||||
returning
|
||||
id,
|
||||
confirmation_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
`
|
||||
|
||||
if (row) {
|
||||
return mapBooking(row)
|
||||
}
|
||||
|
||||
return await getBookingByConfirmationToken(confirmationToken)
|
||||
}
|
||||
90
server/utils/bookings.ts
Normal file
90
server/utils/bookings.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
getBookingModeLabel,
|
||||
getTicketCatalogItem,
|
||||
isBookingMode,
|
||||
isTicketType
|
||||
} from '~~/shared/booking'
|
||||
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
|
||||
|
||||
import { assertBadRequest } from './http'
|
||||
|
||||
export function parseCreateBookingInput(body: {
|
||||
customerName?: string
|
||||
customerPhone?: string
|
||||
bookingMode?: BookingMode
|
||||
quantity?: number
|
||||
ticketType?: TicketType
|
||||
personInChargeId?: string
|
||||
}) {
|
||||
const customerName = normalizeFullName(body.customerName || '')
|
||||
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
||||
const bookingMode = body.bookingMode
|
||||
const ticketType = body.ticketType
|
||||
const quantity = Number(body.quantity)
|
||||
const personInChargeId = (body.personInChargeId || '').trim()
|
||||
|
||||
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
|
||||
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must contain 8 to 15 digits')
|
||||
assertBadRequest(isBookingMode(bookingMode), 'Booking mode is invalid')
|
||||
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
|
||||
assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid')
|
||||
assertBadRequest(personInChargeId, 'Person in charge is required')
|
||||
|
||||
return {
|
||||
customerName,
|
||||
customerPhone,
|
||||
bookingMode,
|
||||
quantity,
|
||||
ticketType,
|
||||
personInChargeId
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
|
||||
const ticket = getTicketCatalogItem(booking.ticketType)
|
||||
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
|
||||
|
||||
return [
|
||||
"I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.",
|
||||
'',
|
||||
`Name: ${booking.customerName}`,
|
||||
`Phone Number: ${booking.customerPhone}`,
|
||||
`Booking Mode: ${getBookingModeLabel(booking.bookingMode)}`,
|
||||
`Quantity: ${booking.quantity}`,
|
||||
`Ticket Category: ${ticketLabel}`,
|
||||
`Seats Covered: ${booking.seatCount}`,
|
||||
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
||||
'',
|
||||
'PIC confirmation link:',
|
||||
confirmationUrl
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function parseBookingCapacityInput(body: {
|
||||
totalTables?: number | string | null
|
||||
}): Pick<BookingCapacitySettings, 'totalTables'> {
|
||||
const totalTables = parseOptionalInteger(body.totalTables)
|
||||
|
||||
assertBadRequest(totalTables === null || totalTables >= 0, 'Total tables must be 0 or greater')
|
||||
|
||||
return {
|
||||
totalTables
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalInteger(value: number | string | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = typeof value === 'number'
|
||||
? value
|
||||
: Number.parseInt(String(value), 10)
|
||||
|
||||
assertBadRequest(Number.isInteger(parsed), 'Capacity values must be whole numbers')
|
||||
|
||||
return parsed
|
||||
}
|
||||
@@ -61,6 +61,43 @@ async function initializeDatabase() {
|
||||
on user_passkeys (user_id)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists bookings (
|
||||
id text primary key,
|
||||
confirmation_token text not null unique,
|
||||
customer_name text not null,
|
||||
customer_phone text not null,
|
||||
booking_mode text not null check (booking_mode in ('table', 'pax')),
|
||||
quantity integer not null check (quantity >= 1),
|
||||
seat_count integer not null check (seat_count >= 1),
|
||||
ticket_type text not null check (ticket_type in ('vip', 'supporter')),
|
||||
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 check (status in ('pending', 'confirmed')) default 'pending',
|
||||
confirmed_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists booking_settings (
|
||||
id text primary key,
|
||||
total_tables integer,
|
||||
total_seats integer,
|
||||
updated_at timestamptz not null default now()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
insert into booking_settings (id)
|
||||
values ('default')
|
||||
on conflict (id) do nothing
|
||||
`
|
||||
|
||||
const [existingSuperAdmin] = await sql<{ id: string }[]>`
|
||||
select id
|
||||
from users
|
||||
|
||||
@@ -256,6 +256,36 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getPublicContactById(contactId: string): Promise<PublicContact | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
|
||||
select
|
||||
users.id,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role
|
||||
from users
|
||||
where users.id = ${contactId}
|
||||
and users.is_active = true
|
||||
and users.phone_number is not null
|
||||
and users.phone_number <> ''
|
||||
limit 1
|
||||
`
|
||||
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: row.phone_number || '',
|
||||
role: row.role
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(input: {
|
||||
username: string
|
||||
fullName: string
|
||||
|
||||
@@ -2,25 +2,13 @@ import type { H3Event } from 'h3'
|
||||
|
||||
import type { AuthenticatorTransportFuture, WebAuthnCredential } from '@simplewebauthn/server'
|
||||
|
||||
import { getRequestURL } from 'h3'
|
||||
|
||||
import { decodeBase64Url, randomToken } from './base64url'
|
||||
import { getAppOrigin } from './app-url'
|
||||
import { getRedisClient } from './redis'
|
||||
import type { PasskeyRecord } from './user-repository'
|
||||
|
||||
const CHALLENGE_TTL_SECONDS = 60 * 5
|
||||
|
||||
function getAppOrigin(event: H3Event) {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (config.public.appUrl) {
|
||||
return new URL(config.public.appUrl).origin
|
||||
}
|
||||
|
||||
const url = getRequestURL(event)
|
||||
return `${url.protocol}//${url.host}`
|
||||
}
|
||||
|
||||
export function getWebAuthnConfig(event: H3Event) {
|
||||
const config = useRuntimeConfig()
|
||||
const origin = getAppOrigin(event)
|
||||
|
||||
Reference in New Issue
Block a user