Add paginated users/roles, user status, frontend permissions, profile pictures, identifier file storage

- Users page: paginated, searchable, sortable with inline roles (no N+1)
- Roles page: paginated, searchable, sortable + /roles/all for dropdowns
- User is_active field with migration, PATCH toggle, auth check (disabled=401)
- Frontend permission checks: auth store loads permissions, sidebar/buttons conditional
- Profile pictures via file storage for users and members, avatar component
- Identifier images use file storage API instead of base64
- Fix TypeScript errors across admin UI
- 64 API tests passing (10 new)
This commit is contained in:
Ryan Moon
2026-03-29 08:16:34 -05:00
parent 92371ff228
commit b9f78639e2
48 changed files with 1689 additions and 643 deletions

View File

@@ -139,6 +139,35 @@ suite('Files', { tags: ['files', 'storage'] }, (t) => {
t.assert.equal(res.status, 400)
})
t.test('uploads profile picture for user entity type', { tags: ['upload', 'profile'] }, async () => {
// Get the current test user ID from the users list
const usersRes = await t.api.get('/v1/users')
const testUser = usersRes.data.data[0]
t.assert.ok(testUser)
const formData = new FormData()
formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'avatar.jpg')
formData.append('entityType', 'user')
formData.append('entityId', testUser.id)
formData.append('category', 'profile')
const res = await fetch(`${t.baseUrl}/v1/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${t.token}` },
body: formData,
})
const data = await res.json()
t.assert.equal(res.status, 201)
t.assert.equal(data.entityType, 'user')
t.assert.equal(data.category, 'profile')
// Verify it shows up in files list
const listRes = await t.api.get('/v1/files', { entityType: 'user', entityId: testUser.id })
t.assert.status(listRes, 200)
t.assert.greaterThan(listRes.data.data.length, 0)
})
t.test('returns 404 for missing file', { tags: ['read'] }, async () => {
const res = await t.api.get('/v1/files/a0000000-0000-0000-0000-999999999999')
t.assert.status(res, 404)

View File

@@ -172,6 +172,7 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
t.test('roles list returns system roles', { tags: ['roles'] }, async () => {
const res = await t.api.get('/v1/roles')
t.assert.status(res, 200)
t.assert.ok(res.data.pagination)
const slugs = res.data.data.map((r: { slug: string }) => r.slug)
t.assert.includes(slugs, 'admin')
t.assert.includes(slugs, 'manager')
@@ -181,6 +182,13 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
t.assert.includes(slugs, 'viewer')
})
t.test('roles/all returns unpaginated list', { tags: ['roles'] }, async () => {
const res = await t.api.get('/v1/roles/all')
t.assert.status(res, 200)
t.assert.greaterThan(res.data.data.length, 5)
t.assert.equal(res.data.pagination, undefined)
})
t.test('permissions list returns all system permissions', { tags: ['permissions'] }, async () => {
const res = await t.api.get('/v1/permissions')
t.assert.status(res, 200)
@@ -196,6 +204,120 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => {
t.assert.equal(deleteRes.status, 403)
})
t.test('roles search by name', { tags: ['roles', 'search'] }, async () => {
const res = await t.api.get('/v1/roles', { q: 'admin' })
t.assert.status(res, 200)
t.assert.greaterThan(res.data.data.length, 0)
t.assert.ok(res.data.data.every((r: { name: string }) => r.name.toLowerCase().includes('admin')))
})
t.test('roles sort by name descending', { tags: ['roles', 'sort'] }, async () => {
const res = await t.api.get('/v1/roles', { sort: 'name', order: 'desc' })
t.assert.status(res, 200)
const names = res.data.data.map((r: { name: string }) => r.name)
const sorted = [...names].sort().reverse()
t.assert.equal(JSON.stringify(names), JSON.stringify(sorted))
})
t.test('users list is paginated with roles', { tags: ['users', 'pagination'] }, async () => {
const res = await t.api.get('/v1/users')
t.assert.status(res, 200)
t.assert.ok(res.data.pagination)
t.assert.greaterThan(res.data.data.length, 0)
// Each user should have a roles array
const first = res.data.data[0]
t.assert.ok(Array.isArray(first.roles))
})
t.test('users search by name', { tags: ['users', 'search'] }, async () => {
// Create a user with a distinctive name
await fetch(`${t.baseUrl}/v1/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' },
body: JSON.stringify({ email: `searchme-${Date.now()}@test.com`, password: 'testpassword1234', firstName: 'Searchable', lastName: 'Pessoa', role: 'staff' }),
})
const res = await t.api.get('/v1/users', { q: 'Searchable' })
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 1)
t.assert.equal(res.data.data[0].firstName, 'Searchable')
})
t.test('users sort by email ascending', { tags: ['users', 'sort'] }, async () => {
const res = await t.api.get('/v1/users', { sort: 'email', order: 'asc' })
t.assert.status(res, 200)
const emails = res.data.data.map((u: { email: string }) => u.email)
const sorted = [...emails].sort()
t.assert.equal(JSON.stringify(emails), JSON.stringify(sorted))
})
t.test('can disable and re-enable a user', { tags: ['users', 'status'] }, async () => {
// Create a user
const email = `disable-${Date.now()}@test.com`
const password = 'testpassword1234'
const regRes = await fetch(`${t.baseUrl}/v1/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' },
body: JSON.stringify({ email, password, firstName: 'Disable', lastName: 'Me', role: 'staff' }),
})
const regData = await regRes.json() as { user: { id: string } }
const userId = regData.user.id
// Disable the user
const disableRes = await t.api.patch(`/v1/users/${userId}/status`, { isActive: false })
t.assert.status(disableRes, 200)
t.assert.equal(disableRes.data.isActive, false)
// Disabled user cannot authenticate
const loginRes = await fetch(`${t.baseUrl}/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const loginData = await loginRes.json() as { token: string }
// Try to use the token — should get 401
const authRes = await fetch(`${t.baseUrl}/v1/accounts`, {
headers: { Authorization: `Bearer ${loginData.token}` },
})
t.assert.equal(authRes.status, 401)
// Re-enable the user
const enableRes = await t.api.patch(`/v1/users/${userId}/status`, { isActive: true })
t.assert.status(enableRes, 200)
t.assert.equal(enableRes.data.isActive, true)
// Now they can authenticate again
const reLoginRes = await fetch(`${t.baseUrl}/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const reLoginData = await reLoginRes.json() as { token: string }
const reAuthRes = await fetch(`${t.baseUrl}/v1/accounts`, {
headers: { Authorization: `Bearer ${reLoginData.token}` },
})
// Will be 403 (no permissions) but NOT 401 (not disabled)
t.assert.notEqual(reAuthRes.status, 401)
})
t.test('cannot disable yourself', { tags: ['users', 'status'] }, async () => {
// Get current user ID from the users list
const usersRes = await t.api.get('/v1/users')
const currentUser = usersRes.data.data.find((u: { email: string }) => u.email === 'test@forte.dev')
t.assert.ok(currentUser)
const res = await t.api.patch(`/v1/users/${currentUser.id}/status`, { isActive: false })
t.assert.equal(res.status, 400)
})
t.test('users list includes isActive field', { tags: ['users'] }, async () => {
const res = await t.api.get('/v1/users')
t.assert.status(res, 200)
const first = res.data.data[0]
t.assert.equal(typeof first.isActive, 'boolean')
})
t.test('can create and delete custom role', { tags: ['roles'] }, async () => {
const createRes = await t.api.post('/v1/roles', {
name: 'Temp Role',

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "is_active" boolean NOT NULL DEFAULT true;

View File

@@ -99,6 +99,13 @@
"when": 1774730000000,
"tag": "0013_rbac",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1774740000000,
"tag": "0014_user_is_active",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { pgTable, uuid, varchar, timestamp, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core'
import { pgTable, uuid, varchar, timestamp, pgEnum, uniqueIndex, boolean } from 'drizzle-orm/pg-core'
import { companies } from './stores.js'
export const userRoleEnum = pgEnum('user_role', [
@@ -19,6 +19,7 @@ export const users = pgTable('user', {
firstName: varchar('first_name', { length: 100 }).notNull(),
lastName: varchar('last_name', { length: 100 }).notNull(),
role: userRoleEnum('role').notNull().default('staff'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})

View File

@@ -1,5 +1,7 @@
import fp from 'fastify-plugin'
import fjwt from '@fastify/jwt'
import { eq } from 'drizzle-orm'
import { users } from '../db/schema/users.js'
import { RbacService } from '../services/rbac.service.js'
declare module 'fastify' {
@@ -72,6 +74,18 @@ export const authPlugin = fp(async (app) => {
await request.jwtVerify()
request.companyId = request.user.companyId
// Check if user account is active
const [dbUser] = await app.db
.select({ isActive: users.isActive })
.from(users)
.where(eq(users.id, request.user.id))
.limit(1)
if (!dbUser || !dbUser.isActive) {
reply.status(401).send({ error: { message: 'Account disabled', statusCode: 401 } })
return
}
// Load permissions from DB and expand with inheritance
const permSlugs = await RbacService.getUserPermissions(app.db, request.user.id)
request.permissions = expandPermissions(permSlugs)

View File

@@ -2,7 +2,7 @@ import fp from 'fastify-plugin'
import { AppError, ValidationError } from '../lib/errors.js'
export const errorHandlerPlugin = fp(async (app) => {
app.setErrorHandler((error, request, reply) => {
app.setErrorHandler((error: Error & { statusCode?: number; stack?: string }, request, reply) => {
// Use AppError statusCode if available, else Fastify's, else 500
const statusCode = error instanceof AppError
? error.statusCode

View File

@@ -124,6 +124,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
const account = await AccountService.create(app.db, request.companyId, {
name: `${member.firstName} ${member.lastName}`,
billingMode: 'consolidated',
})
targetAccountId = account.id
}
@@ -148,8 +149,9 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { memberId } = request.params as { memberId: string }
const identifiers = await MemberIdentifierService.listByMember(app.db, request.companyId, memberId)
return reply.send({ data: identifiers })
const params = PaginationSchema.parse(request.query)
const result = await MemberIdentifierService.listByMember(app.db, request.companyId, memberId, params)
return reply.send(result)
})
app.patch('/identifiers/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
@@ -191,8 +193,9 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
app.get('/accounts/:accountId/processor-links', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const links = await ProcessorLinkService.listByAccount(app.db, request.companyId, accountId)
return reply.send({ data: links })
const params = PaginationSchema.parse(request.query)
const result = await ProcessorLinkService.listByAccount(app.db, request.companyId, accountId, params)
return reply.send(result)
})
app.patch('/processor-links/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
@@ -227,8 +230,9 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
app.get('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const methods = await PaymentMethodService.listByAccount(app.db, request.companyId, accountId)
return reply.send({ data: methods })
const params = PaginationSchema.parse(request.query)
const result = await PaymentMethodService.listByAccount(app.db, request.companyId, accountId, params)
return reply.send(result)
})
app.get('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
@@ -270,8 +274,9 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
app.get('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const exemptions = await TaxExemptionService.listByAccount(app.db, request.companyId, accountId)
return reply.send({ data: exemptions })
const params = PaginationSchema.parse(request.query)
const result = await TaxExemptionService.listByAccount(app.db, request.companyId, accountId, params)
return reply.send(result)
})
app.get('/tax-exemptions/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {

View File

@@ -176,7 +176,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
// Generate a signed reset token that expires in 1 hour
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' }, { expiresIn: '1h' })
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '1h' })
const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}`
request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated')

View File

@@ -42,7 +42,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
}
// Validate entityType is a known type
const allowedEntityTypes = ['member', 'member_identifier', 'product', 'rental_agreement', 'repair_ticket']
const allowedEntityTypes = ['user', 'member', 'member_identifier', 'product', 'rental_agreement', 'repair_ticket']
if (!allowedEntityTypes.includes(entityType)) {
throw new ValidationError(`Invalid entityType: ${entityType}`)
}

View File

@@ -1,28 +1,110 @@
import type { FastifyPluginAsync } from 'fastify'
import { eq, and } from 'drizzle-orm'
import { eq, and, count, sql, type Column } from 'drizzle-orm'
import { PaginationSchema } from '@forte/shared/schemas'
import { RbacService } from '../../services/rbac.service.js'
import { ValidationError } from '../../lib/errors.js'
import { users } from '../../db/schema/users.js'
import { roles, userRoles } from '../../db/schema/rbac.js'
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../../utils/pagination.js'
export const rbacRoutes: FastifyPluginAsync = async (app) => {
// --- Users list ---
app.get('/users', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
const allUsers = await app.db
const params = PaginationSchema.parse(request.query)
const baseWhere = eq(users.companyId, request.companyId)
const searchCondition = params.q
? buildSearchCondition(params.q, [users.firstName, users.lastName, users.email])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
name: users.lastName,
email: users.email,
created_at: users.createdAt,
}
let query = app.db
.select({
id: users.id,
email: users.email,
firstName: users.firstName,
lastName: users.lastName,
role: users.role,
isActive: users.isActive,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.companyId, request.companyId))
.orderBy(users.lastName)
.where(where)
.$dynamic()
return reply.send({ data: allUsers })
query = withSort(query, params.sort, params.order, sortableColumns, users.lastName)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
app.db.select({ total: count() }).from(users).where(where),
])
// Attach roles to each user
const userIds = data.map((u) => u.id)
const roleAssignments = userIds.length > 0
? await app.db
.select({
userId: userRoles.userId,
roleId: roles.id,
roleName: roles.name,
roleSlug: roles.slug,
isSystem: roles.isSystem,
})
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(sql`${userRoles.userId} IN ${userIds}`)
: []
const rolesByUser = new Map<string, { id: string; name: string; slug: string; isSystem: boolean }[]>()
for (const ra of roleAssignments) {
const list = rolesByUser.get(ra.userId) ?? []
list.push({ id: ra.roleId, name: ra.roleName, slug: ra.roleSlug, isSystem: ra.isSystem })
rolesByUser.set(ra.userId, list)
}
const usersWithRoles = data.map((u) => ({
...u,
roles: rolesByUser.get(u.id) ?? [],
}))
return reply.send(paginatedResponse(usersWithRoles, total, params.page, params.limit))
})
// --- User status ---
app.patch('/users/:userId/status', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => {
const { userId } = request.params as { userId: string }
const { isActive } = request.body as { isActive?: boolean }
if (typeof isActive !== 'boolean') {
throw new ValidationError('isActive (boolean) is required')
}
// Prevent disabling yourself
if (userId === request.user.id) {
throw new ValidationError('Cannot change your own account status')
}
const [updated] = await app.db
.update(users)
.set({ isActive, updatedAt: new Date() })
.where(and(eq(users.id, userId), eq(users.companyId, request.companyId)))
.returning({ id: users.id, isActive: users.isActive })
if (!updated) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
request.log.info({ userId, isActive, changedBy: request.user.id }, 'User status changed')
return reply.send(updated)
})
// --- Permissions ---
app.get('/permissions', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
@@ -33,7 +115,18 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => {
// --- Roles ---
app.get('/roles', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
const data = await RbacService.listRoles(app.db, request.companyId)
const params = PaginationSchema.parse(request.query)
const result = await RbacService.listRoles(app.db, request.companyId, params)
return reply.send(result)
})
// Unpaginated list for dropdowns/selectors
app.get('/roles/all', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => {
const data = await app.db
.select()
.from(roles)
.where(and(eq(roles.companyId, request.companyId), eq(roles.isActive, true)))
.orderBy(roles.name)
return reply.send({ data })
})

View File

@@ -1,4 +1,4 @@
import { eq, and, sql, count, exists } from 'drizzle-orm'
import { eq, and, sql, count, exists, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import {
accounts,
@@ -30,11 +30,11 @@ import {
} from '../utils/pagination.js'
async function generateUniqueNumber(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
table: typeof accounts | typeof members,
column: typeof accounts.accountNumber | typeof members.memberNumber,
companyId: string,
companyIdColumn: typeof accounts.companyId,
companyIdColumn: Column,
): Promise<string> {
for (let attempt = 0; attempt < 10; attempt++) {
const num = String(Math.floor(100000 + Math.random() * 900000))
@@ -58,7 +58,7 @@ function normalizeAddress(address?: { street?: string; city?: string; state?: st
}
export const AccountService = {
async create(db: PostgresJsDatabase, companyId: string, input: AccountCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: AccountCreateInput) {
const accountNumber = await generateUniqueNumber(db, accounts, accounts.accountNumber, companyId, accounts.companyId)
const [account] = await db
@@ -78,7 +78,7 @@ export const AccountService = {
return account
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [account] = await db
.select()
.from(accounts)
@@ -88,7 +88,7 @@ export const AccountService = {
return account ?? null
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: AccountUpdateInput) {
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: AccountUpdateInput) {
const [account] = await db
.update(accounts)
.set({ ...input, updatedAt: new Date() })
@@ -98,7 +98,7 @@ export const AccountService = {
return account ?? null
},
async softDelete(db: PostgresJsDatabase, companyId: string, id: string) {
async softDelete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [account] = await db
.update(accounts)
.set({ isActive: false, updatedAt: new Date() })
@@ -108,7 +108,7 @@ export const AccountService = {
return account ?? null
},
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true))
const accountSearch = params.q
@@ -133,7 +133,7 @@ export const AccountService = {
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof accounts.name> = {
const sortableColumns: Record<string, Column> = {
name: accounts.name,
email: accounts.email,
created_at: accounts.createdAt,
@@ -155,7 +155,7 @@ export const AccountService = {
export const MemberService = {
async create(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
companyId: string,
input: {
accountId: string
@@ -210,7 +210,7 @@ export const MemberService = {
return member
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [member] = await db
.select()
.from(members)
@@ -220,7 +220,7 @@ export const MemberService = {
return member ?? null
},
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = eq(members.companyId, companyId)
const searchCondition = params.q
@@ -229,7 +229,7 @@ export const MemberService = {
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof members.firstName> = {
const sortableColumns: Record<string, Column> = {
first_name: members.firstName,
last_name: members.lastName,
email: members.email,
@@ -268,14 +268,14 @@ export const MemberService = {
},
async listByAccount(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
companyId: string,
accountId: string,
params: PaginationInput,
) {
const where = and(eq(members.companyId, companyId), eq(members.accountId, accountId))
const sortableColumns: Record<string, typeof members.firstName> = {
const sortableColumns: Record<string, Column> = {
first_name: members.firstName,
last_name: members.lastName,
created_at: members.createdAt,
@@ -294,7 +294,7 @@ export const MemberService = {
},
async update(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
companyId: string,
id: string,
input: {
@@ -325,7 +325,7 @@ export const MemberService = {
return member ?? null
},
async move(db: PostgresJsDatabase, companyId: string, memberId: string, targetAccountId: string) {
async move(db: PostgresJsDatabase<any>, companyId: string, memberId: string, targetAccountId: string) {
const member = await this.getById(db, companyId, memberId)
if (!member) return null
@@ -351,7 +351,7 @@ export const MemberService = {
return updated
},
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [member] = await db
.delete(members)
.where(and(eq(members.id, id), eq(members.companyId, companyId)))
@@ -362,7 +362,7 @@ export const MemberService = {
}
export const ProcessorLinkService = {
async create(db: PostgresJsDatabase, companyId: string, input: ProcessorLinkCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: ProcessorLinkCreateInput) {
const [link] = await db
.insert(accountProcessorLinks)
.values({
@@ -375,7 +375,7 @@ export const ProcessorLinkService = {
return link
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [link] = await db
.select()
.from(accountProcessorLinks)
@@ -384,19 +384,31 @@ export const ProcessorLinkService = {
return link ?? null
},
async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) {
return db
.select()
.from(accountProcessorLinks)
.where(
and(
eq(accountProcessorLinks.companyId, companyId),
eq(accountProcessorLinks.accountId, accountId),
),
)
async listByAccount(db: PostgresJsDatabase<any>, companyId: string, accountId: string, params: PaginationInput) {
const baseWhere = and(eq(accountProcessorLinks.companyId, companyId), eq(accountProcessorLinks.accountId, accountId))
const searchCondition = params.q
? buildSearchCondition(params.q, [accountProcessorLinks.processorCustomerId, accountProcessorLinks.processor])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
processor: accountProcessorLinks.processor,
created_at: accountProcessorLinks.createdAt,
}
let query = db.select().from(accountProcessorLinks).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, accountProcessorLinks.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(accountProcessorLinks).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: ProcessorLinkUpdateInput) {
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: ProcessorLinkUpdateInput) {
const [link] = await db
.update(accountProcessorLinks)
.set(input)
@@ -405,7 +417,7 @@ export const ProcessorLinkService = {
return link ?? null
},
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [link] = await db
.delete(accountProcessorLinks)
.where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId)))
@@ -415,7 +427,7 @@ export const ProcessorLinkService = {
}
export const PaymentMethodService = {
async create(db: PostgresJsDatabase, companyId: string, input: PaymentMethodCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: PaymentMethodCreateInput) {
// If this is the default, unset any existing default for this account
if (input.isDefault) {
await db
@@ -447,7 +459,7 @@ export const PaymentMethodService = {
return method
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [method] = await db
.select()
.from(accountPaymentMethods)
@@ -456,19 +468,32 @@ export const PaymentMethodService = {
return method ?? null
},
async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) {
return db
.select()
.from(accountPaymentMethods)
.where(
and(
eq(accountPaymentMethods.companyId, companyId),
eq(accountPaymentMethods.accountId, accountId),
),
)
async listByAccount(db: PostgresJsDatabase<any>, companyId: string, accountId: string, params: PaginationInput) {
const baseWhere = and(eq(accountPaymentMethods.companyId, companyId), eq(accountPaymentMethods.accountId, accountId))
const searchCondition = params.q
? buildSearchCondition(params.q, [accountPaymentMethods.cardBrand, accountPaymentMethods.lastFour])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
card_brand: accountPaymentMethods.cardBrand,
processor: accountPaymentMethods.processor,
created_at: accountPaymentMethods.createdAt,
}
let query = db.select().from(accountPaymentMethods).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, accountPaymentMethods.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(accountPaymentMethods).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: PaymentMethodUpdateInput) {
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: PaymentMethodUpdateInput) {
// If setting as default, unset existing default
if (input.isDefault) {
const existing = await this.getById(db, companyId, id)
@@ -494,7 +519,7 @@ export const PaymentMethodService = {
return method ?? null
},
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [method] = await db
.delete(accountPaymentMethods)
.where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId)))
@@ -504,7 +529,7 @@ export const PaymentMethodService = {
}
export const TaxExemptionService = {
async create(db: PostgresJsDatabase, companyId: string, input: TaxExemptionCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: TaxExemptionCreateInput) {
const [exemption] = await db
.insert(taxExemptions)
.values({
@@ -521,7 +546,7 @@ export const TaxExemptionService = {
return exemption
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [exemption] = await db
.select()
.from(taxExemptions)
@@ -530,19 +555,33 @@ export const TaxExemptionService = {
return exemption ?? null
},
async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) {
return db
.select()
.from(taxExemptions)
.where(
and(
eq(taxExemptions.companyId, companyId),
eq(taxExemptions.accountId, accountId),
),
)
async listByAccount(db: PostgresJsDatabase<any>, companyId: string, accountId: string, params: PaginationInput) {
const baseWhere = and(eq(taxExemptions.companyId, companyId), eq(taxExemptions.accountId, accountId))
const searchCondition = params.q
? buildSearchCondition(params.q, [taxExemptions.certificateNumber, taxExemptions.certificateType, taxExemptions.issuingState])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
certificate_number: taxExemptions.certificateNumber,
status: taxExemptions.status,
expires_at: taxExemptions.expiresAt,
created_at: taxExemptions.createdAt,
}
let query = db.select().from(taxExemptions).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, taxExemptions.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(taxExemptions).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: TaxExemptionUpdateInput) {
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: TaxExemptionUpdateInput) {
const [exemption] = await db
.update(taxExemptions)
.set({ ...input, updatedAt: new Date() })
@@ -551,7 +590,7 @@ export const TaxExemptionService = {
return exemption ?? null
},
async approve(db: PostgresJsDatabase, companyId: string, id: string, approvedBy: string) {
async approve(db: PostgresJsDatabase<any>, companyId: string, id: string, approvedBy: string) {
const [exemption] = await db
.update(taxExemptions)
.set({
@@ -565,7 +604,7 @@ export const TaxExemptionService = {
return exemption ?? null
},
async revoke(db: PostgresJsDatabase, companyId: string, id: string, revokedBy: string, reason: string) {
async revoke(db: PostgresJsDatabase<any>, companyId: string, id: string, revokedBy: string, reason: string) {
const [exemption] = await db
.update(taxExemptions)
.set({
@@ -582,7 +621,7 @@ export const TaxExemptionService = {
}
export const MemberIdentifierService = {
async create(db: PostgresJsDatabase, companyId: string, input: MemberIdentifierCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: MemberIdentifierCreateInput) {
// If setting as primary, unset existing primary for this member
if (input.isPrimary) {
await db
@@ -616,19 +655,31 @@ export const MemberIdentifierService = {
return identifier
},
async listByMember(db: PostgresJsDatabase, companyId: string, memberId: string) {
return db
.select()
.from(memberIdentifiers)
.where(
and(
eq(memberIdentifiers.companyId, companyId),
eq(memberIdentifiers.memberId, memberId),
),
)
async listByMember(db: PostgresJsDatabase<any>, companyId: string, memberId: string, params: PaginationInput) {
const baseWhere = and(eq(memberIdentifiers.companyId, companyId), eq(memberIdentifiers.memberId, memberId))
const searchCondition = params.q
? buildSearchCondition(params.q, [memberIdentifiers.value, memberIdentifiers.label, memberIdentifiers.issuingAuthority])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
type: memberIdentifiers.type,
created_at: memberIdentifiers.createdAt,
}
let query = db.select().from(memberIdentifiers).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, memberIdentifiers.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(memberIdentifiers).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [identifier] = await db
.select()
.from(memberIdentifiers)
@@ -637,7 +688,7 @@ export const MemberIdentifierService = {
return identifier ?? null
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: MemberIdentifierUpdateInput) {
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: MemberIdentifierUpdateInput) {
if (input.isPrimary) {
const existing = await this.getById(db, companyId, id)
if (existing) {
@@ -661,7 +712,7 @@ export const MemberIdentifierService = {
return identifier ?? null
},
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [identifier] = await db
.delete(memberIdentifiers)
.where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId)))

View File

@@ -24,7 +24,7 @@ function getExtension(contentType: string): string {
export const FileService = {
async upload(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
storage: StorageProvider,
companyId: string,
input: {
@@ -91,7 +91,7 @@ export const FileService = {
return file
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [file] = await db
.select()
.from(files)
@@ -101,7 +101,7 @@ export const FileService = {
},
async listByEntity(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
companyId: string,
entityType: string,
entityId: string,
@@ -120,7 +120,7 @@ export const FileService = {
},
async delete(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
storage: StorageProvider,
companyId: string,
id: string,

View File

@@ -1,4 +1,4 @@
import { eq, and, count } from 'drizzle-orm'
import { eq, and, count, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { categories, suppliers } from '../db/schema/inventory.js'
import type {
@@ -16,7 +16,7 @@ import {
} from '../utils/pagination.js'
export const CategoryService = {
async create(db: PostgresJsDatabase, companyId: string, input: CategoryCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: CategoryCreateInput) {
const [category] = await db
.insert(categories)
.values({ companyId, ...input })
@@ -24,7 +24,7 @@ export const CategoryService = {
return category
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [category] = await db
.select()
.from(categories)
@@ -33,7 +33,7 @@ export const CategoryService = {
return category ?? null
},
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(categories.companyId, companyId), eq(categories.isActive, true))
const searchCondition = params.q
@@ -42,7 +42,7 @@ export const CategoryService = {
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof categories.name> = {
const sortableColumns: Record<string, Column> = {
name: categories.name,
sort_order: categories.sortOrder,
created_at: categories.createdAt,
@@ -60,7 +60,7 @@ export const CategoryService = {
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: CategoryUpdateInput) {
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: CategoryUpdateInput) {
const [category] = await db
.update(categories)
.set({ ...input, updatedAt: new Date() })
@@ -69,7 +69,7 @@ export const CategoryService = {
return category ?? null
},
async softDelete(db: PostgresJsDatabase, companyId: string, id: string) {
async softDelete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [category] = await db
.update(categories)
.set({ isActive: false, updatedAt: new Date() })
@@ -80,7 +80,7 @@ export const CategoryService = {
}
export const SupplierService = {
async create(db: PostgresJsDatabase, companyId: string, input: SupplierCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: SupplierCreateInput) {
const [supplier] = await db
.insert(suppliers)
.values({ companyId, ...input })
@@ -88,7 +88,7 @@ export const SupplierService = {
return supplier
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [supplier] = await db
.select()
.from(suppliers)
@@ -97,7 +97,7 @@ export const SupplierService = {
return supplier ?? null
},
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true))
const searchCondition = params.q
@@ -106,7 +106,7 @@ export const SupplierService = {
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof suppliers.name> = {
const sortableColumns: Record<string, Column> = {
name: suppliers.name,
created_at: suppliers.createdAt,
}
@@ -123,7 +123,7 @@ export const SupplierService = {
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: SupplierUpdateInput) {
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: SupplierUpdateInput) {
const [supplier] = await db
.update(suppliers)
.set({ ...input, updatedAt: new Date() })
@@ -132,7 +132,7 @@ export const SupplierService = {
return supplier ?? null
},
async softDelete(db: PostgresJsDatabase, companyId: string, id: string) {
async softDelete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [supplier] = await db
.update(suppliers)
.set({ isActive: false, updatedAt: new Date() })

View File

@@ -14,7 +14,7 @@ function createLookupService(
systemSeeds: ReadonlyArray<{ slug: string; name: string; description: string; sortOrder: number }>,
) {
return {
async seedForCompany(db: PostgresJsDatabase, companyId: string) {
async seedForCompany(db: PostgresJsDatabase<any>, companyId: string) {
const existing = await db
.select()
.from(table)
@@ -32,7 +32,7 @@ function createLookupService(
)
},
async list(db: PostgresJsDatabase, companyId: string) {
async list(db: PostgresJsDatabase<any>, companyId: string) {
return db
.select()
.from(table)
@@ -40,7 +40,7 @@ function createLookupService(
.orderBy(table.sortOrder)
},
async getBySlug(db: PostgresJsDatabase, companyId: string, slug: string) {
async getBySlug(db: PostgresJsDatabase<any>, companyId: string, slug: string) {
const [row] = await db
.select()
.from(table)
@@ -49,7 +49,7 @@ function createLookupService(
return row ?? null
},
async create(db: PostgresJsDatabase, companyId: string, input: LookupCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: LookupCreateInput) {
const [row] = await db
.insert(table)
.values({
@@ -64,7 +64,7 @@ function createLookupService(
return row
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: LookupUpdateInput) {
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: LookupUpdateInput) {
// Prevent modifying system rows' slug or system flag
const existing = await db
.select()
@@ -85,7 +85,7 @@ function createLookupService(
return row ?? null
},
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const existing = await db
.select()
.from(table)
@@ -104,7 +104,7 @@ function createLookupService(
return row ?? null
},
async validateSlug(db: PostgresJsDatabase, companyId: string, slug: string): Promise<boolean> {
async validateSlug(db: PostgresJsDatabase<any>, companyId: string, slug: string): Promise<boolean> {
const row = await this.getBySlug(db, companyId, slug)
return row !== null && row.isActive
},

View File

@@ -1,4 +1,4 @@
import { eq, and, count } from 'drizzle-orm'
import { eq, and, count, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js'
import { ValidationError } from '../lib/errors.js'
@@ -18,7 +18,7 @@ import {
import { UnitStatusService, ItemConditionService } from './lookup.service.js'
export const ProductService = {
async create(db: PostgresJsDatabase, companyId: string, input: ProductCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: ProductCreateInput) {
const [product] = await db
.insert(products)
.values({
@@ -32,7 +32,7 @@ export const ProductService = {
return product
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [product] = await db
.select()
.from(products)
@@ -41,7 +41,7 @@ export const ProductService = {
return product ?? null
},
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(products.companyId, companyId), eq(products.isActive, true))
const searchCondition = params.q
@@ -50,7 +50,7 @@ export const ProductService = {
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof products.name> = {
const sortableColumns: Record<string, Column> = {
name: products.name,
sku: products.sku,
brand: products.brand,
@@ -71,7 +71,7 @@ export const ProductService = {
},
async update(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
companyId: string,
id: string,
input: ProductUpdateInput,
@@ -106,7 +106,7 @@ export const ProductService = {
return product ?? null
},
async softDelete(db: PostgresJsDatabase, companyId: string, id: string) {
async softDelete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [product] = await db
.update(products)
.set({ isActive: false, updatedAt: new Date() })
@@ -117,7 +117,7 @@ export const ProductService = {
}
export const InventoryUnitService = {
async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) {
async create(db: PostgresJsDatabase<any>, companyId: string, input: InventoryUnitCreateInput) {
if (input.condition) {
const valid = await ItemConditionService.validateSlug(db, companyId, input.condition)
if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`)
@@ -144,7 +144,7 @@ export const InventoryUnitService = {
return unit
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [unit] = await db
.select()
.from(inventoryUnits)
@@ -154,7 +154,7 @@ export const InventoryUnitService = {
},
async listByProduct(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
companyId: string,
productId: string,
params: PaginationInput,
@@ -164,7 +164,7 @@ export const InventoryUnitService = {
eq(inventoryUnits.productId, productId),
)
const sortableColumns: Record<string, typeof inventoryUnits.serialNumber> = {
const sortableColumns: Record<string, Column> = {
serial_number: inventoryUnits.serialNumber,
status: inventoryUnits.status,
condition: inventoryUnits.condition,
@@ -184,7 +184,7 @@ export const InventoryUnitService = {
},
async update(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
companyId: string,
id: string,
input: InventoryUnitUpdateInput,

View File

@@ -1,12 +1,14 @@
import { eq, and, inArray } from 'drizzle-orm'
import { eq, and, inArray, count, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import type { PaginationInput } from '@forte/shared/schemas'
import { permissions, roles, rolePermissions, userRoles } from '../db/schema/rbac.js'
import { SYSTEM_PERMISSIONS, DEFAULT_ROLES } from '../db/seeds/rbac.js'
import { ForbiddenError } from '../lib/errors.js'
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
export const RbacService = {
/** Seed system permissions (global, run once) */
async seedPermissions(db: PostgresJsDatabase) {
async seedPermissions(db: PostgresJsDatabase<any>) {
const existing = await db.select({ slug: permissions.slug }).from(permissions)
const existingSlugs = new Set(existing.map((p) => p.slug))
@@ -17,7 +19,7 @@ export const RbacService = {
},
/** Seed default roles for a company */
async seedRolesForCompany(db: PostgresJsDatabase, companyId: string) {
async seedRolesForCompany(db: PostgresJsDatabase<any>, companyId: string) {
const existingRoles = await db
.select({ slug: roles.slug })
.from(roles)
@@ -57,7 +59,7 @@ export const RbacService = {
},
/** Get all permissions for a user (union of all role permissions) */
async getUserPermissions(db: PostgresJsDatabase, userId: string): Promise<string[]> {
async getUserPermissions(db: PostgresJsDatabase<any>, userId: string): Promise<string[]> {
const userRoleRecords = await db
.select({ roleId: userRoles.roleId })
.from(userRoles)
@@ -85,21 +87,40 @@ export const RbacService = {
},
/** List all permissions */
async listPermissions(db: PostgresJsDatabase) {
async listPermissions(db: PostgresJsDatabase<any>) {
return db.select().from(permissions).orderBy(permissions.domain, permissions.action)
},
/** List roles for a company */
async listRoles(db: PostgresJsDatabase, companyId: string) {
return db
.select()
.from(roles)
.where(and(eq(roles.companyId, companyId), eq(roles.isActive, true)))
.orderBy(roles.name)
async listRoles(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(roles.companyId, companyId), eq(roles.isActive, true))
const searchCondition = params.q
? buildSearchCondition(params.q, [roles.name, roles.slug])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
name: roles.name,
slug: roles.slug,
created_at: roles.createdAt,
}
let query = db.select().from(roles).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, roles.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(roles).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
/** Get role with its permissions */
async getRoleWithPermissions(db: PostgresJsDatabase, companyId: string, roleId: string) {
async getRoleWithPermissions(db: PostgresJsDatabase<any>, companyId: string, roleId: string) {
const [role] = await db
.select()
.from(roles)
@@ -119,7 +140,7 @@ export const RbacService = {
/** Create a custom role */
async createRole(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
companyId: string,
input: { name: string; slug: string; description?: string; permissionSlugs: string[] },
) {
@@ -139,7 +160,7 @@ export const RbacService = {
},
/** Update role permissions (replace all) */
async setRolePermissions(db: PostgresJsDatabase, roleId: string, permissionSlugs: string[]) {
async setRolePermissions(db: PostgresJsDatabase<any>, roleId: string, permissionSlugs: string[]) {
// Delete existing
await db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId))
@@ -160,7 +181,7 @@ export const RbacService = {
/** Update a role */
async updateRole(
db: PostgresJsDatabase,
db: PostgresJsDatabase<any>,
companyId: string,
roleId: string,
input: { name?: string; description?: string; permissionSlugs?: string[] },
@@ -184,7 +205,7 @@ export const RbacService = {
},
/** Delete a custom role */
async deleteRole(db: PostgresJsDatabase, companyId: string, roleId: string) {
async deleteRole(db: PostgresJsDatabase<any>, companyId: string, roleId: string) {
const [role] = await db
.select()
.from(roles)
@@ -207,7 +228,7 @@ export const RbacService = {
},
/** Assign a role to a user */
async assignRole(db: PostgresJsDatabase, userId: string, roleId: string, assignedBy?: string) {
async assignRole(db: PostgresJsDatabase<any>, userId: string, roleId: string, assignedBy?: string) {
const [existing] = await db
.select()
.from(userRoles)
@@ -225,7 +246,7 @@ export const RbacService = {
},
/** Remove a role from a user */
async removeRole(db: PostgresJsDatabase, userId: string, roleId: string) {
async removeRole(db: PostgresJsDatabase<any>, userId: string, roleId: string) {
const [removed] = await db
.delete(userRoles)
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)))
@@ -235,7 +256,7 @@ export const RbacService = {
},
/** Get roles assigned to a user */
async getUserRoles(db: PostgresJsDatabase, userId: string) {
async getUserRoles(db: PostgresJsDatabase<any>, userId: string) {
return db
.select({
id: roles.id,