Files
dticket.tootaio.com/server/utils/user-repository.ts
xiaomai 8541c4a2d1 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
2026-04-12 21:43:30 +08:00

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}
`
}