Add WhatsApp API integration for automated receipt delivery Enforce country codes for all phone number inputs (defaults to +60)
520 lines
12 KiB
TypeScript
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}
|
|
`
|
|
}
|