feat(users): add drag-and-drop reordering for PICs
Introduce pic_sort_order to persist custom user ordering Replace data table with a custom draggable grid layout Add API endpoint to handle bulk order updates
This commit is contained in:
24
server/api/admin/users/order.patch.ts
Normal file
24
server/api/admin/users/order.patch.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { requireRole } from '../../../utils/auth'
|
||||
import { assertBadRequest } from '../../../utils/http'
|
||||
import { listUsers, reorderUsers } from '../../../utils/user-repository'
|
||||
import { parseUserOrderInput } from '../../../utils/users'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireRole(event, 'super_admin')
|
||||
|
||||
const body = await readBody<{
|
||||
userIds?: unknown
|
||||
}>(event)
|
||||
const { userIds } = parseUserOrderInput(body)
|
||||
const users = await listUsers()
|
||||
const existingIds = new Set(users.map((user) => user.id))
|
||||
|
||||
assertBadRequest(userIds.length === users.length, 'User order must include every user')
|
||||
assertBadRequest(userIds.every((userId) => existingIds.has(userId)), 'User order contains an unknown user')
|
||||
|
||||
await reorderUsers(userIds)
|
||||
|
||||
return {
|
||||
users: await listUsers()
|
||||
}
|
||||
})
|
||||
@@ -41,6 +41,29 @@ async function initializeDatabase() {
|
||||
add column if not exists phone_number text
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table users
|
||||
add column if not exists pic_sort_order integer not null default 0
|
||||
`
|
||||
|
||||
await sql`
|
||||
update users
|
||||
set pic_sort_order = seed.sort_order
|
||||
from (
|
||||
select
|
||||
id,
|
||||
row_number() over (
|
||||
order by
|
||||
case when role = 'super_admin' then 0 else 1 end,
|
||||
created_at asc,
|
||||
full_name asc
|
||||
) as sort_order
|
||||
from users
|
||||
) as seed
|
||||
where users.id = seed.id
|
||||
and users.pic_sort_order = 0
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists user_passkeys (
|
||||
id text primary key,
|
||||
|
||||
@@ -15,6 +15,7 @@ type DbUserRow = {
|
||||
full_name: string
|
||||
phone_number: string | null
|
||||
role: UserRole
|
||||
pic_sort_order: number | string
|
||||
password_hash: string
|
||||
must_change_password: boolean
|
||||
is_active: boolean
|
||||
@@ -83,6 +84,7 @@ function mapAuthUser(row: DbUserRow): AuthUser {
|
||||
fullName: row.full_name,
|
||||
phoneNumber: row.phone_number ? normalizePhoneNumber(row.phone_number) : null,
|
||||
role: row.role,
|
||||
picSortOrder: parseInteger(row.pic_sort_order),
|
||||
isActive: row.is_active,
|
||||
mustChangePassword: row.must_change_password,
|
||||
needsPasskeySetup: passkeyCount === 0,
|
||||
@@ -147,6 +149,7 @@ export async function getUserById(userId: string): Promise<UserAuthRecord | null
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role,
|
||||
users.pic_sort_order,
|
||||
users.password_hash,
|
||||
users.must_change_password,
|
||||
users.is_active,
|
||||
@@ -178,6 +181,7 @@ export async function getUserByUsername(username: string): Promise<UserAuthRecor
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role,
|
||||
users.pic_sort_order,
|
||||
users.password_hash,
|
||||
users.must_change_password,
|
||||
users.is_active,
|
||||
@@ -209,6 +213,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role,
|
||||
users.pic_sort_order,
|
||||
users.password_hash,
|
||||
users.must_change_password,
|
||||
users.is_active,
|
||||
@@ -223,7 +228,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
|
||||
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.pic_sort_order asc,
|
||||
users.created_at asc
|
||||
`
|
||||
|
||||
@@ -234,18 +239,19 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
|
||||
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role' | 'pic_sort_order'>[]>`
|
||||
select
|
||||
users.id,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role
|
||||
users.role,
|
||||
users.pic_sort_order
|
||||
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.pic_sort_order asc,
|
||||
users.full_name asc
|
||||
`
|
||||
|
||||
@@ -253,7 +259,8 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
||||
role: row.role
|
||||
role: row.role,
|
||||
picSortOrder: parseInteger(row.pic_sort_order)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -261,12 +268,13 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
|
||||
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role' | 'pic_sort_order'>[]>`
|
||||
select
|
||||
users.id,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role
|
||||
users.role,
|
||||
users.pic_sort_order
|
||||
from users
|
||||
where users.id = ${contactId}
|
||||
and users.is_active = true
|
||||
@@ -283,7 +291,8 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
||||
role: row.role
|
||||
role: row.role,
|
||||
picSortOrder: parseInteger(row.pic_sort_order)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +314,7 @@ export async function createUser(input: {
|
||||
full_name,
|
||||
phone_number,
|
||||
role,
|
||||
pic_sort_order,
|
||||
password_hash,
|
||||
must_change_password,
|
||||
is_active,
|
||||
@@ -316,6 +326,7 @@ export async function createUser(input: {
|
||||
${input.fullName},
|
||||
${input.phoneNumber},
|
||||
${input.role},
|
||||
(select coalesce(max(pic_sort_order), 0) + 1 from users),
|
||||
${input.passwordHash},
|
||||
true,
|
||||
true,
|
||||
@@ -327,6 +338,7 @@ export async function createUser(input: {
|
||||
full_name,
|
||||
phone_number,
|
||||
role,
|
||||
pic_sort_order,
|
||||
password_hash,
|
||||
must_change_password,
|
||||
is_active,
|
||||
@@ -361,6 +373,21 @@ export async function updateUserProfile(input: {
|
||||
return getUserById(input.userId)
|
||||
}
|
||||
|
||||
export async function reorderUsers(userIds: string[]) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
for (const [index, userId] of userIds.entries()) {
|
||||
await sql`
|
||||
update users
|
||||
set
|
||||
pic_sort_order = ${index + 1},
|
||||
updated_at = now()
|
||||
where id = ${userId}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserPassword(input: {
|
||||
userId: string
|
||||
passwordHash: string
|
||||
|
||||
@@ -73,3 +73,19 @@ export function parseUserProfileInput(body: {
|
||||
role
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUserOrderInput(body: {
|
||||
userIds?: unknown
|
||||
}) {
|
||||
assertBadRequest(Array.isArray(body.userIds), 'User ids must be an array')
|
||||
assertBadRequest(body.userIds.every((value) => typeof value === 'string' && value.trim()), 'Every user id is required')
|
||||
|
||||
const userIds = body.userIds.map((value) => value.trim())
|
||||
const uniqueUserIds = new Set(userIds)
|
||||
|
||||
assertBadRequest(uniqueUserIds.size === userIds.length, 'User ids must be unique')
|
||||
|
||||
return {
|
||||
userIds
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user