Files
dticket.tootaio.com/server/utils/user-repository.ts
xiaomai c214d643dd feat: send ticket receipts via WhatsApp and normalize phone numbers
Add WhatsApp API integration for automated receipt delivery
Enforce country codes for all phone number inputs (defaults to +60)
2026-04-27 13:12:25 +08:00

520 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 { normalizePhoneNumber } 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 ? normalizePhoneNumber(row.phone_number) : null,
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: normalizePhoneNumber(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: normalizePhoneNumber(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}
`
}