mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
Fix admin settings persistence and batch user operations (#358)
* fix: persist admin settings and batch user operations Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98 * test: cover admin quota edge cases Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98 * fix: avoid duplicate batch quota toast Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98
This commit is contained in:
@@ -462,11 +462,14 @@ describe('createPersonalOrg — org name and quota edge cases', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('sign-up with default_org_quota set to zero does not insert org_quota row', async () => {
|
||||
it('sign-up with default_org_quota set to zero falls back to DEFAULT_ORG_QUOTA', async () => {
|
||||
const ctx = await createTestApp()
|
||||
await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '0' })
|
||||
const res = await signUp(ctx, 'zero-quota@example.com')
|
||||
expect(res.status).toBe(200)
|
||||
const quotas = await ctx.db.select().from(schema.orgQuotas)
|
||||
expect(quotas).toHaveLength(1)
|
||||
expect(quotas[0].quota).toBe(10485760)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+3
-7
@@ -6,7 +6,7 @@ import { genericOAuth } from 'better-auth/plugins/generic-oauth'
|
||||
import { adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
|
||||
import { count, eq, like } from 'drizzle-orm'
|
||||
import { customAlphabet, nanoid } from 'nanoid'
|
||||
import { SignupMode } from '../shared/constants'
|
||||
import { DEFAULT_ORG_QUOTA, SignupMode } from '../shared/constants'
|
||||
import {
|
||||
BUILTIN_PROVIDER_IDS,
|
||||
OAUTH_PROVIDER_KEY_PATTERN,
|
||||
@@ -459,15 +459,11 @@ async function createPersonalOrg(
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
if (defaultQuota > 0) {
|
||||
await db.insert(orgQuotas).values({ id: nanoid(), orgId, quota: defaultQuota, used: 0 })
|
||||
}
|
||||
await db.insert(orgQuotas).values({ id: nanoid(), orgId, quota: defaultQuota, used: 0 })
|
||||
|
||||
return orgId
|
||||
}
|
||||
|
||||
const DEFAULT_ORG_QUOTA = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
async function getDefaultOrgQuota(db: Database): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ value: systemOptions.value })
|
||||
@@ -476,5 +472,5 @@ async function getDefaultOrgQuota(db: Database): Promise<number> {
|
||||
const raw = rows[0]?.value
|
||||
if (raw == null) return DEFAULT_ORG_QUOTA
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : DEFAULT_ORG_QUOTA
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_ORG_QUOTA
|
||||
}
|
||||
|
||||
@@ -196,16 +196,22 @@ describe('Auth API', () => {
|
||||
expect(rows[0].used).toBe(0)
|
||||
})
|
||||
|
||||
it('signup with default_org_quota set to 0 does NOT create an org_quotas row', async () => {
|
||||
it('signup with default_org_quota set to 0 creates a built-in default org_quotas row', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '0' })
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
const signUpRes = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Test', email: 'quota-zero@example.com', password: 'password123456' }),
|
||||
})
|
||||
const rows = await db.select().from(schema.orgQuotas)
|
||||
expect(rows).toHaveLength(0)
|
||||
const body = (await signUpRes.json()) as { user: { id: string } }
|
||||
const orgs = await db
|
||||
.select()
|
||||
.from(authSchema.organization)
|
||||
.where(eq(authSchema.organization.slug, `personal-${body.user.id}`))
|
||||
const rows = await db.select().from(schema.orgQuotas).where(eq(schema.orgQuotas.orgId, orgs[0].id))
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].quota).toBe(10485760)
|
||||
})
|
||||
|
||||
it('sign-in with a malformed stored password hash returns a non-200 error response', async () => {
|
||||
|
||||
@@ -109,6 +109,28 @@ describe('Admin Quotas API', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('PUT /api/admin/quotas/:orgId rejects zero quota', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/quotas/some-org', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quota: 0 }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('PUT /api/admin/quotas/:orgId rejects decimal quota', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/quotas/some-org', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quota: 1.5 }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('PUT /api/admin/quotas/:orgId works without Pro license', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { recordActivity } from '../services/activity'
|
||||
import { findPersonalOrg } from '../services/org'
|
||||
|
||||
const updateQuotaSchema = z.object({
|
||||
quota: z.number().min(0),
|
||||
quota: z.number().int().positive(),
|
||||
})
|
||||
|
||||
const adminQuotas = new Hono<Env>()
|
||||
|
||||
@@ -83,4 +83,14 @@ describe('System API — options CRUD', () => {
|
||||
const del = await app.request('/api/system/options/site_name', { method: 'DELETE' })
|
||||
expect(del.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects invalid default organization quota values', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
for (const value of ['0', '-1', '1.5', 'abc']) {
|
||||
const res = await putOption(app, admin, 'default_org_quota', { value })
|
||||
expect(res.status).toBe(400)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +52,13 @@ const app = new Hono<Env>()
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'default_org_quota') {
|
||||
const quota = Number(body.value)
|
||||
if (!Number.isInteger(quota) || quota <= 0) {
|
||||
return c.json({ error: 'Default organization quota must be a positive number' }, 400)
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ key: systemOptions.key, public: systemOptions.public })
|
||||
.from(systemOptions)
|
||||
|
||||
@@ -14,11 +14,11 @@ async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']
|
||||
return { Cookie: signInRes.headers.getSetCookie().join('; ') }
|
||||
}
|
||||
|
||||
async function signUpUser(app: ReturnType<typeof import('../app')['createApp']>, email: string) {
|
||||
async function signUpUser(app: ReturnType<typeof import('../app')['createApp']>, email: string, name = 'Other User') {
|
||||
const res = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Other User', email, password: 'password123456' }),
|
||||
body: JSON.stringify({ name, email, password: 'password123456' }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
@@ -59,6 +59,30 @@ describe('Admin Users API', () => {
|
||||
expect(body.items[0].orgName).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GET /api/admin/users filters by name, username, or email with filtered totals', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await signUpUser(app, 'match-email@example.com', 'Email Match')
|
||||
await signUpUser(app, 'username-target@example.com', 'Plain Name')
|
||||
await signUpUser(app, 'other@example.com', 'Other Person')
|
||||
|
||||
const byName = await app.request('/api/admin/users?search=email%20match', { headers })
|
||||
expect(byName.status).toBe(200)
|
||||
const byNameBody = (await byName.json()) as { items: Array<Record<string, unknown>>; total: number }
|
||||
expect(byNameBody.total).toBe(1)
|
||||
expect(byNameBody.items[0].email).toBe('match-email@example.com')
|
||||
|
||||
const byUsername = await app.request('/api/admin/users?search=username-target', { headers })
|
||||
const byUsernameBody = (await byUsername.json()) as { items: Array<Record<string, unknown>>; total: number }
|
||||
expect(byUsernameBody.total).toBe(1)
|
||||
expect(byUsernameBody.items[0].email).toBe('username-target@example.com')
|
||||
|
||||
const byEmail = await app.request('/api/admin/users?search=other@example.com', { headers })
|
||||
const byEmailBody = (await byEmail.json()) as { items: Array<Record<string, unknown>>; total: number }
|
||||
expect(byEmailBody.total).toBe(1)
|
||||
expect(byEmailBody.items[0].email).toBe('other@example.com')
|
||||
})
|
||||
|
||||
it('PATCH /api/admin/users/:id disables a user', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -160,4 +184,160 @@ describe('Admin Users API', () => {
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PATCH /api/admin/users/batch disables and enables users', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await signUpUser(app, 'batch1@example.com')
|
||||
await signUpUser(app, 'batch2@example.com')
|
||||
const users = await db.all<{ id: string }>(
|
||||
sql`SELECT id FROM user WHERE email IN ('batch1@example.com', 'batch2@example.com') ORDER BY email`,
|
||||
)
|
||||
const ids = users.map((row) => row.id)
|
||||
|
||||
const disable = await app.request('/api/admin/users/batch', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'disable', ids }),
|
||||
})
|
||||
expect(disable.status).toBe(200)
|
||||
expect((await disable.json()) as Record<string, unknown>).toMatchObject({ updated: 2, ids, status: 'disabled' })
|
||||
const disabled = await db.all<{ banned: number }>(sql`SELECT banned FROM user WHERE id IN (${ids[0]}, ${ids[1]})`)
|
||||
expect(disabled.every((row) => row.banned === 1)).toBe(true)
|
||||
|
||||
const enable = await app.request('/api/admin/users/batch', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'enable', ids }),
|
||||
})
|
||||
expect(enable.status).toBe(200)
|
||||
const enabled = await db.all<{ banned: number }>(sql`SELECT banned FROM user WHERE id IN (${ids[0]}, ${ids[1]})`)
|
||||
expect(enabled.every((row) => row.banned === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('PATCH /api/admin/users/batch sets quota for personal orgs', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await signUpUser(app, 'quota1@example.com')
|
||||
await signUpUser(app, 'quota2@example.com')
|
||||
const users = await db.all<{ id: string }>(
|
||||
sql`SELECT id FROM user WHERE email IN ('quota1@example.com', 'quota2@example.com') ORDER BY email`,
|
||||
)
|
||||
const ids = users.map((row) => row.id)
|
||||
|
||||
const res = await app.request('/api/admin/users/batch', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set_quota', ids, quota: 123456 }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { updated: number; orgIds: string[]; quota: number }
|
||||
expect(body.updated).toBe(2)
|
||||
expect(body.quota).toBe(123456)
|
||||
const quotas = await db.all<{ quota: number }>(
|
||||
sql`SELECT quota FROM org_quotas WHERE org_id IN (${body.orgIds[0]}, ${body.orgIds[1]})`,
|
||||
)
|
||||
expect(quotas.map((row) => row.quota)).toEqual([123456, 123456])
|
||||
})
|
||||
|
||||
it('PATCH /api/admin/users/batch creates missing personal quota rows', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await signUpUser(app, 'quota-missing@example.com')
|
||||
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'quota-missing@example.com'`)
|
||||
const userId = users[0].id
|
||||
const orgs = await db.all<{ id: string }>(sql`SELECT id FROM organization WHERE slug = ${`personal-${userId}`}`)
|
||||
await db.run(sql`DELETE FROM org_quotas WHERE org_id = ${orgs[0].id}`)
|
||||
|
||||
const res = await app.request('/api/admin/users/batch', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set_quota', ids: [userId], quota: 654321 }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const quotas = await db.all<{ quota: number }>(sql`SELECT quota FROM org_quotas WHERE org_id = ${orgs[0].id}`)
|
||||
expect(quotas).toHaveLength(1)
|
||||
expect(quotas[0].quota).toBe(654321)
|
||||
})
|
||||
|
||||
it('PATCH /api/admin/users/batch fails when selected user has no personal org', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await signUpUser(app, 'no-personal-org@example.com')
|
||||
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'no-personal-org@example.com'`)
|
||||
const userId = users[0].id
|
||||
await db.run(sql`DELETE FROM member WHERE user_id = ${userId}`)
|
||||
|
||||
const res = await app.request('/api/admin/users/batch', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set_quota', ids: [userId], quota: 123456 }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(await res.json()).toEqual({ error: `Personal organization not found for user(s): ${userId}` })
|
||||
})
|
||||
|
||||
it('DELETE /api/admin/users/batch deletes selected users', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await signUpUser(app, 'delete1@example.com')
|
||||
await signUpUser(app, 'delete2@example.com')
|
||||
const users = await db.all<{ id: string }>(
|
||||
sql`SELECT id FROM user WHERE email IN ('delete1@example.com', 'delete2@example.com') ORDER BY email`,
|
||||
)
|
||||
const ids = users.map((row) => row.id)
|
||||
|
||||
const res = await app.request('/api/admin/users/batch', {
|
||||
method: 'DELETE',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()) as Record<string, unknown>).toMatchObject({ deleted: 2, ids })
|
||||
const remaining = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE id IN (${ids[0]}, ${ids[1]})`)
|
||||
expect(remaining).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('batch operations reject missing users instead of skipping them', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const patch = await app.request('/api/admin/users/batch', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'disable', ids: ['missing-user'] }),
|
||||
})
|
||||
expect(patch.status).toBe(404)
|
||||
expect(await patch.json()).toEqual({ error: 'User not found: missing-user' })
|
||||
|
||||
const quota = await app.request('/api/admin/users/batch', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set_quota', ids: ['missing-user'], quota: 123456 }),
|
||||
})
|
||||
expect(quota.status).toBe(404)
|
||||
expect(await quota.json()).toEqual({ error: 'User not found: missing-user' })
|
||||
|
||||
const del = await app.request('/api/admin/users/batch', {
|
||||
method: 'DELETE',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: ['missing-user'] }),
|
||||
})
|
||||
expect(del.status).toBe(404)
|
||||
expect(await del.json()).toEqual({ error: 'User not found: missing-user' })
|
||||
})
|
||||
|
||||
it('PATCH /api/admin/users/batch rejects non-positive quota values', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/users/batch', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set_quota', ids: ['some-user'], quota: 0 }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
+80
-2
@@ -4,20 +4,98 @@ import { z } from 'zod'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { recordActivity } from '../services/activity'
|
||||
import { deleteUser, listUsers, setUserStatus } from '../services/user'
|
||||
import {
|
||||
deleteUser,
|
||||
deleteUsers,
|
||||
listUsers,
|
||||
setUserStatus,
|
||||
setUsersPersonalQuota,
|
||||
setUsersStatus,
|
||||
} from '../services/user'
|
||||
|
||||
const updateStatusSchema = z.object({
|
||||
status: z.enum(['active', 'disabled']),
|
||||
})
|
||||
|
||||
const userIdsSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
|
||||
const batchPatchSchema = z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.enum(['disable', 'enable']),
|
||||
ids: z.array(z.string().min(1)).min(1),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('set_quota'),
|
||||
ids: z.array(z.string().min(1)).min(1),
|
||||
quota: z.number().int().positive(),
|
||||
}),
|
||||
])
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use(requireAdmin)
|
||||
.get('/', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const page = Math.max(1, Number(c.req.query('page') ?? '1'))
|
||||
const pageSize = Math.min(100, Math.max(1, Number(c.req.query('pageSize') ?? '20')))
|
||||
const search = c.req.query('search')
|
||||
|
||||
const result = await listUsers(db, page, pageSize)
|
||||
const result = await listUsers(db, page, pageSize, search)
|
||||
return c.json(result)
|
||||
})
|
||||
.patch('/batch', zValidator('json', batchPatchSchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const adminUserId = c.get('userId')!
|
||||
const orgId = c.get('orgId')!
|
||||
const body = c.req.valid('json')
|
||||
|
||||
if (body.action === 'set_quota') {
|
||||
const result = await setUsersPersonalQuota(db, body.ids, body.quota)
|
||||
if ('error' in result) return c.json({ error: result.error }, result.status)
|
||||
|
||||
await recordActivity(db, {
|
||||
orgId,
|
||||
userId: adminUserId,
|
||||
action: 'quota_update',
|
||||
targetType: 'quota',
|
||||
targetName: 'batch',
|
||||
metadata: result,
|
||||
})
|
||||
return c.json(result)
|
||||
}
|
||||
|
||||
const status = body.action === 'disable' ? 'disabled' : 'active'
|
||||
const result = await setUsersStatus(db, body.ids, status)
|
||||
if ('error' in result) return c.json({ error: result.error }, result.status)
|
||||
|
||||
await recordActivity(db, {
|
||||
orgId,
|
||||
userId: adminUserId,
|
||||
action: status === 'disabled' ? 'user_disable' : 'user_enable',
|
||||
targetType: 'user',
|
||||
targetName: 'batch',
|
||||
metadata: { ...result, status },
|
||||
})
|
||||
return c.json({ ...result, status })
|
||||
})
|
||||
.delete('/batch', zValidator('json', userIdsSchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const adminUserId = c.get('userId')!
|
||||
const orgId = c.get('orgId')!
|
||||
const { ids } = c.req.valid('json')
|
||||
|
||||
const result = await deleteUsers(db, ids)
|
||||
if ('error' in result) return c.json({ error: result.error }, result.status)
|
||||
|
||||
await recordActivity(db, {
|
||||
orgId,
|
||||
userId: adminUserId,
|
||||
action: 'user_delete',
|
||||
targetType: 'user',
|
||||
targetName: 'batch',
|
||||
metadata: result,
|
||||
})
|
||||
return c.json(result)
|
||||
})
|
||||
.patch('/:id', zValidator('json', updateStatusSchema), async (c) => {
|
||||
|
||||
+106
-3
@@ -1,5 +1,7 @@
|
||||
import { and, count, desc, eq, sql } from 'drizzle-orm'
|
||||
import { and, count, desc, eq, inArray, or, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { member, organization, user } from '../db/auth-schema'
|
||||
import { orgQuotas } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
export interface UserWithOrg {
|
||||
@@ -14,17 +16,33 @@ export interface UserWithOrg {
|
||||
orgName: string | null
|
||||
}
|
||||
|
||||
export interface UserOperationFailure {
|
||||
error: string
|
||||
status: 404
|
||||
}
|
||||
|
||||
export async function listUsers(
|
||||
db: Database,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
search?: string,
|
||||
): Promise<{ items: UserWithOrg[]; total: number }> {
|
||||
const offset = (page - 1) * pageSize
|
||||
const term = search?.trim().toLowerCase()
|
||||
const filter = term
|
||||
? or(
|
||||
sql`lower(${user.name}) like ${`%${term}%`}`,
|
||||
sql`lower(${user.username}) like ${`%${term}%`}`,
|
||||
sql`lower(${user.email}) like ${`%${term}%`}`,
|
||||
)
|
||||
: undefined
|
||||
|
||||
const countRows = await db.select({ total: count() }).from(user)
|
||||
const countRows = filter
|
||||
? await db.select({ total: count() }).from(user).where(filter)
|
||||
: await db.select({ total: count() }).from(user)
|
||||
const total = countRows[0]?.total ?? 0
|
||||
|
||||
const rows = await db
|
||||
const query = db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -42,6 +60,8 @@ export async function listUsers(
|
||||
organization,
|
||||
and(eq(organization.id, member.organizationId), eq(organization.slug, sql`'personal-' || ${user.id}`)),
|
||||
)
|
||||
|
||||
const rows = await (filter ? query.where(filter) : query)
|
||||
.groupBy(user.id)
|
||||
.orderBy(desc(user.createdAt))
|
||||
.limit(pageSize)
|
||||
@@ -73,3 +93,86 @@ export async function deleteUser(db: Database, userId: string): Promise<boolean>
|
||||
await db.delete(user).where(eq(user.id, userId))
|
||||
return true
|
||||
}
|
||||
|
||||
export async function setUsersStatus(
|
||||
db: Database,
|
||||
userIds: string[],
|
||||
status: 'active' | 'disabled',
|
||||
): Promise<{ updated: number; ids: string[] } | UserOperationFailure> {
|
||||
const existingIds = await requireUsers(db, userIds)
|
||||
if ('error' in existingIds) return existingIds
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({ banned: status === 'disabled' })
|
||||
.where(inArray(user.id, existingIds))
|
||||
return { updated: existingIds.length, ids: existingIds }
|
||||
}
|
||||
|
||||
export async function deleteUsers(
|
||||
db: Database,
|
||||
userIds: string[],
|
||||
): Promise<{ deleted: number; ids: string[] } | UserOperationFailure> {
|
||||
const existingIds = await requireUsers(db, userIds)
|
||||
if ('error' in existingIds) return existingIds
|
||||
|
||||
await db.delete(user).where(inArray(user.id, existingIds))
|
||||
return { deleted: existingIds.length, ids: existingIds }
|
||||
}
|
||||
|
||||
export async function setUsersPersonalQuota(
|
||||
db: Database,
|
||||
userIds: string[],
|
||||
quota: number,
|
||||
): Promise<{ updated: number; userIds: string[]; orgIds: string[]; quota: number } | UserOperationFailure> {
|
||||
const existingIds = await requireUsers(db, userIds)
|
||||
if ('error' in existingIds) return existingIds
|
||||
|
||||
const rows = await db
|
||||
.select({ userId: user.id, orgId: organization.id })
|
||||
.from(user)
|
||||
.innerJoin(member, eq(member.userId, user.id))
|
||||
.innerJoin(
|
||||
organization,
|
||||
and(eq(organization.id, member.organizationId), eq(organization.slug, sql`'personal-' || ${user.id}`)),
|
||||
)
|
||||
.where(inArray(user.id, existingIds))
|
||||
|
||||
if (rows.length !== existingIds.length) {
|
||||
const found = new Set(rows.map((row) => row.userId))
|
||||
const missing = existingIds.filter((id) => !found.has(id))
|
||||
return { error: `Personal organization not found for user(s): ${missing.join(', ')}`, status: 404 }
|
||||
}
|
||||
|
||||
const orgIds = rows.map((row) => row.orgId)
|
||||
const existingQuotaRows = await db
|
||||
.select({ orgId: orgQuotas.orgId })
|
||||
.from(orgQuotas)
|
||||
.where(inArray(orgQuotas.orgId, orgIds))
|
||||
const existingOrgIds = new Set(existingQuotaRows.map((row) => row.orgId))
|
||||
const nowMissing = orgIds.filter((orgId) => !existingOrgIds.has(orgId))
|
||||
|
||||
if (existingOrgIds.size > 0) {
|
||||
await db
|
||||
.update(orgQuotas)
|
||||
.set({ quota })
|
||||
.where(inArray(orgQuotas.orgId, [...existingOrgIds]))
|
||||
}
|
||||
|
||||
for (const orgId of nowMissing) {
|
||||
await db.insert(orgQuotas).values({ id: nanoid(), orgId, quota, used: 0 })
|
||||
}
|
||||
|
||||
return { updated: rows.length, userIds: existingIds, orgIds, quota }
|
||||
}
|
||||
|
||||
async function requireUsers(db: Database, userIds: string[]): Promise<string[] | UserOperationFailure> {
|
||||
const uniqueIds = [...new Set(userIds)]
|
||||
const rows = await db.select({ id: user.id }).from(user).where(inArray(user.id, uniqueIds))
|
||||
if (rows.length !== uniqueIds.length) {
|
||||
const found = new Set(rows.map((row) => row.id))
|
||||
const missing = uniqueIds.filter((id) => !found.has(id))
|
||||
return { error: `User not found: ${missing.join(', ')}`, status: 404 }
|
||||
}
|
||||
return uniqueIds
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user