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
519 lines
12 KiB
TypeScript
519 lines
12 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
|
|
import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server'
|
|
|
|
import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth'
|
|
|
|
import { encodeBase64Url, toIsoString } from './base64url'
|
|
import { ensureDatabaseReady } from './db-init'
|
|
import { getSqlClient } from './postgres'
|
|
|
|
type DbUserRow = {
|
|
id: string
|
|
username: string
|
|
full_name: string
|
|
phone_number: string | null
|
|
role: UserRole
|
|
password_hash: string
|
|
must_change_password: boolean
|
|
is_active: boolean
|
|
created_by: string | null
|
|
created_at: Date | string
|
|
last_login_at: Date | string | null
|
|
passkey_count: number | string
|
|
}
|
|
|
|
type DbPasskeyRow = {
|
|
id: string
|
|
user_id: string
|
|
credential_id: string
|
|
public_key: string
|
|
counter: number | string
|
|
device_type: CredentialDeviceType
|
|
backed_up: boolean
|
|
transports: AuthenticatorTransportFuture[] | string
|
|
label: string
|
|
created_at: Date | string
|
|
last_used_at: Date | string | null
|
|
}
|
|
|
|
export interface UserAuthRecord extends AuthUser {
|
|
passwordHash: string
|
|
createdBy: string | null
|
|
}
|
|
|
|
export interface PasskeyRecord {
|
|
id: string
|
|
userId: string
|
|
credentialId: string
|
|
publicKey: string
|
|
counter: number
|
|
deviceType: CredentialDeviceType
|
|
backedUp: boolean
|
|
transports: AuthenticatorTransportFuture[]
|
|
label: string
|
|
createdAt: string
|
|
lastUsedAt: string | null
|
|
}
|
|
|
|
function parseInteger(value: number | string): number {
|
|
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
|
}
|
|
|
|
function parseTransports(value: AuthenticatorTransportFuture[] | string): AuthenticatorTransportFuture[] {
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
return Array.isArray(parsed) ? parsed : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function mapAuthUser(row: DbUserRow): AuthUser {
|
|
const passkeyCount = parseInteger(row.passkey_count)
|
|
|
|
return {
|
|
id: row.id,
|
|
username: row.username,
|
|
fullName: row.full_name,
|
|
phoneNumber: row.phone_number,
|
|
role: row.role,
|
|
isActive: row.is_active,
|
|
mustChangePassword: row.must_change_password,
|
|
needsPasskeySetup: passkeyCount === 0,
|
|
passkeyCount,
|
|
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
|
lastLoginAt: toIsoString(row.last_login_at)
|
|
}
|
|
}
|
|
|
|
function mapManagedUser(row: DbUserRow): ManagedUser {
|
|
return {
|
|
...mapAuthUser(row),
|
|
createdBy: row.created_by
|
|
}
|
|
}
|
|
|
|
function mapUserAuthRecord(row: DbUserRow): UserAuthRecord {
|
|
return {
|
|
...mapAuthUser(row),
|
|
passwordHash: row.password_hash,
|
|
createdBy: row.created_by
|
|
}
|
|
}
|
|
|
|
function mapPasskeyRecord(row: DbPasskeyRow): PasskeyRecord {
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
credentialId: row.credential_id,
|
|
publicKey: row.public_key,
|
|
counter: parseInteger(row.counter),
|
|
deviceType: row.device_type,
|
|
backedUp: row.backed_up,
|
|
transports: parseTransports(row.transports),
|
|
label: row.label,
|
|
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
|
lastUsedAt: toIsoString(row.last_used_at)
|
|
}
|
|
}
|
|
|
|
function mapPasskeySummary(row: DbPasskeyRow): PasskeySummary {
|
|
const record = mapPasskeyRecord(row)
|
|
|
|
return {
|
|
id: record.id,
|
|
label: record.label,
|
|
createdAt: record.createdAt,
|
|
lastUsedAt: record.lastUsedAt,
|
|
deviceType: record.deviceType,
|
|
backedUp: record.backedUp
|
|
}
|
|
}
|
|
|
|
export async function getUserById(userId: string): Promise<UserAuthRecord | null> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbUserRow[]>`
|
|
select
|
|
users.id,
|
|
users.username,
|
|
users.full_name,
|
|
users.phone_number,
|
|
users.role,
|
|
users.password_hash,
|
|
users.must_change_password,
|
|
users.is_active,
|
|
users.created_by,
|
|
users.created_at,
|
|
users.last_login_at,
|
|
coalesce(passkey_totals.passkey_count, 0) as passkey_count
|
|
from users
|
|
left join (
|
|
select user_id, count(*)::int as passkey_count
|
|
from user_passkeys
|
|
group by user_id
|
|
) as passkey_totals on passkey_totals.user_id = users.id
|
|
where users.id = ${userId}
|
|
limit 1
|
|
`
|
|
|
|
return row ? mapUserAuthRecord(row) : null
|
|
}
|
|
|
|
export async function getUserByUsername(username: string): Promise<UserAuthRecord | null> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbUserRow[]>`
|
|
select
|
|
users.id,
|
|
users.username,
|
|
users.full_name,
|
|
users.phone_number,
|
|
users.role,
|
|
users.password_hash,
|
|
users.must_change_password,
|
|
users.is_active,
|
|
users.created_by,
|
|
users.created_at,
|
|
users.last_login_at,
|
|
coalesce(passkey_totals.passkey_count, 0) as passkey_count
|
|
from users
|
|
left join (
|
|
select user_id, count(*)::int as passkey_count
|
|
from user_passkeys
|
|
group by user_id
|
|
) as passkey_totals on passkey_totals.user_id = users.id
|
|
where users.username = ${username}
|
|
limit 1
|
|
`
|
|
|
|
return row ? mapUserAuthRecord(row) : null
|
|
}
|
|
|
|
export async function listUsers(): Promise<ManagedUser[]> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const rows = await sql<DbUserRow[]>`
|
|
select
|
|
users.id,
|
|
users.username,
|
|
users.full_name,
|
|
users.phone_number,
|
|
users.role,
|
|
users.password_hash,
|
|
users.must_change_password,
|
|
users.is_active,
|
|
users.created_by,
|
|
users.created_at,
|
|
users.last_login_at,
|
|
coalesce(passkey_totals.passkey_count, 0) as passkey_count
|
|
from users
|
|
left join (
|
|
select user_id, count(*)::int as passkey_count
|
|
from user_passkeys
|
|
group by user_id
|
|
) as passkey_totals on passkey_totals.user_id = users.id
|
|
order by
|
|
case when users.role = 'super_admin' then 0 else 1 end,
|
|
users.created_at asc
|
|
`
|
|
|
|
return rows.map(mapManagedUser)
|
|
}
|
|
|
|
export async function listPublicContacts(): Promise<PublicContact[]> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const rows = 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.is_active = true
|
|
and users.phone_number is not null
|
|
and users.phone_number <> ''
|
|
order by
|
|
case when users.role = 'super_admin' then 0 else 1 end,
|
|
users.full_name asc
|
|
`
|
|
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
fullName: row.full_name,
|
|
phoneNumber: row.phone_number || '',
|
|
role: row.role
|
|
}))
|
|
}
|
|
|
|
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
|
|
phoneNumber: string
|
|
role: UserRole
|
|
passwordHash: string
|
|
createdBy: string
|
|
}): Promise<UserAuthRecord> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbUserRow[]>`
|
|
insert into users (
|
|
id,
|
|
username,
|
|
full_name,
|
|
phone_number,
|
|
role,
|
|
password_hash,
|
|
must_change_password,
|
|
is_active,
|
|
created_by
|
|
)
|
|
values (
|
|
${randomUUID()},
|
|
${input.username},
|
|
${input.fullName},
|
|
${input.phoneNumber},
|
|
${input.role},
|
|
${input.passwordHash},
|
|
true,
|
|
true,
|
|
${input.createdBy}
|
|
)
|
|
returning
|
|
id,
|
|
username,
|
|
full_name,
|
|
phone_number,
|
|
role,
|
|
password_hash,
|
|
must_change_password,
|
|
is_active,
|
|
created_by,
|
|
created_at,
|
|
last_login_at,
|
|
0::int as passkey_count
|
|
`
|
|
|
|
return mapUserAuthRecord(row)
|
|
}
|
|
|
|
export async function updateUserProfile(input: {
|
|
userId: string
|
|
fullName: string
|
|
phoneNumber: string
|
|
role: UserRole
|
|
}) {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
await sql`
|
|
update users
|
|
set
|
|
full_name = ${input.fullName},
|
|
phone_number = ${input.phoneNumber},
|
|
role = ${input.role},
|
|
updated_at = now()
|
|
where id = ${input.userId}
|
|
`
|
|
|
|
return getUserById(input.userId)
|
|
}
|
|
|
|
export async function updateUserPassword(input: {
|
|
userId: string
|
|
passwordHash: string
|
|
mustChangePassword: boolean
|
|
}) {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
await sql`
|
|
update users
|
|
set
|
|
password_hash = ${input.passwordHash},
|
|
must_change_password = ${input.mustChangePassword},
|
|
updated_at = now()
|
|
where id = ${input.userId}
|
|
`
|
|
}
|
|
|
|
export async function updateLastLogin(userId: string) {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
await sql`
|
|
update users
|
|
set
|
|
last_login_at = now(),
|
|
updated_at = now()
|
|
where id = ${userId}
|
|
`
|
|
}
|
|
|
|
export async function listUserPasskeys(userId: string): Promise<PasskeySummary[]> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const rows = await sql<DbPasskeyRow[]>`
|
|
select
|
|
id,
|
|
user_id,
|
|
credential_id,
|
|
public_key,
|
|
counter,
|
|
device_type,
|
|
backed_up,
|
|
transports,
|
|
label,
|
|
created_at,
|
|
last_used_at
|
|
from user_passkeys
|
|
where user_id = ${userId}
|
|
order by created_at asc
|
|
`
|
|
|
|
return rows.map(mapPasskeySummary)
|
|
}
|
|
|
|
export async function getCredentialForVerification(credentialId: string): Promise<PasskeyRecord | null> {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const [row] = await sql<DbPasskeyRow[]>`
|
|
select
|
|
id,
|
|
user_id,
|
|
credential_id,
|
|
public_key,
|
|
counter,
|
|
device_type,
|
|
backed_up,
|
|
transports,
|
|
label,
|
|
created_at,
|
|
last_used_at
|
|
from user_passkeys
|
|
where credential_id = ${credentialId}
|
|
limit 1
|
|
`
|
|
|
|
return row ? mapPasskeyRecord(row) : null
|
|
}
|
|
|
|
export async function listCredentialDescriptors(userId: string) {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
const rows = await sql<Pick<DbPasskeyRow, 'credential_id' | 'transports'>[]>`
|
|
select credential_id, transports
|
|
from user_passkeys
|
|
where user_id = ${userId}
|
|
order by created_at asc
|
|
`
|
|
|
|
return rows.map((row) => ({
|
|
id: row.credential_id,
|
|
transports: parseTransports(row.transports)
|
|
}))
|
|
}
|
|
|
|
export async function createUserPasskey(input: {
|
|
userId: string
|
|
credentialId: string
|
|
publicKey: Uint8Array
|
|
counter: number
|
|
deviceType: CredentialDeviceType
|
|
backedUp: boolean
|
|
transports: AuthenticatorTransportFuture[]
|
|
label: string
|
|
}) {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
await sql`
|
|
insert into user_passkeys (
|
|
id,
|
|
user_id,
|
|
credential_id,
|
|
public_key,
|
|
counter,
|
|
device_type,
|
|
backed_up,
|
|
transports,
|
|
label
|
|
)
|
|
values (
|
|
${randomUUID()},
|
|
${input.userId},
|
|
${input.credentialId},
|
|
${encodeBase64Url(input.publicKey)},
|
|
${input.counter},
|
|
${input.deviceType},
|
|
${input.backedUp},
|
|
${sql.json(input.transports)},
|
|
${input.label}
|
|
)
|
|
`
|
|
}
|
|
|
|
export async function updateCredentialCounter(input: {
|
|
credentialId: string
|
|
counter: number
|
|
deviceType: CredentialDeviceType
|
|
backedUp: boolean
|
|
}) {
|
|
await ensureDatabaseReady()
|
|
const sql = getSqlClient()
|
|
|
|
await sql`
|
|
update user_passkeys
|
|
set
|
|
counter = ${input.counter},
|
|
device_type = ${input.deviceType},
|
|
backed_up = ${input.backedUp},
|
|
last_used_at = now()
|
|
where credential_id = ${input.credentialId}
|
|
`
|
|
}
|