Phase 1 scaffold: auth for all apps

- Backend: Fastify with two listeners (public + internal), routes, services, DB migration + seed
- client_app: Flutter with BLoC, all auth screens (welcome, display name, register, OTP, force-register)
- mitra_app: Flutter with BLoC, OTP-only login
- control_center: React + Vite, email/password login, mitra/user management, anonymity settings
- Docs: phase1 plan, API contract, client app mockup
- CLAUDE.md and shared memory for all subprojects

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 10:08:42 +08:00
commit a7a2a32d27
85 changed files with 3953 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
import Fastify from 'fastify'
import sensible from '@fastify/sensible'
import { mitraManagementRoutes } from './routes/internal/mitra.routes.js'
import { ccUserRoutes } from './routes/internal/cc-user.routes.js'
import { rolesRoutes } from './routes/internal/roles.routes.js'
import { internalAuthRoutes } from './routes/internal/auth.routes.js'
import { internalConfigRoutes } from './routes/internal/config.routes.js'
import { errorHandler } from './plugins/error-handler.js'
export const buildInternalApp = async () => {
const app = Fastify({ logger: true })
await app.register(sensible)
app.setErrorHandler(errorHandler)
app.register(internalAuthRoutes, { prefix: '/internal/auth' })
app.register(mitraManagementRoutes, { prefix: '/internal/mitras' })
app.register(ccUserRoutes, { prefix: '/internal/control-center-users' })
app.register(rolesRoutes, { prefix: '/internal/roles' })
app.register(internalConfigRoutes, { prefix: '/internal/config' })
return app
}

21
backend/src/app.public.js Normal file
View File

@@ -0,0 +1,21 @@
import Fastify from 'fastify'
import sensible from '@fastify/sensible'
import { customerRoutes } from './routes/public/customer.routes.js'
import { clientAuthRoutes } from './routes/public/client.auth.routes.js'
import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js'
import { sharedConfigRoutes } from './routes/public/shared.config.routes.js'
import { errorHandler } from './plugins/error-handler.js'
export const buildPublicApp = async () => {
const app = Fastify({ logger: true })
await app.register(sensible)
app.setErrorHandler(errorHandler)
app.register(customerRoutes, { prefix: '/api/shared/customer' })
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
app.register(clientAuthRoutes, { prefix: '/api/client/auth' })
app.register(mitraAuthRoutes, { prefix: '/api/mitra/auth' })
return app
}

10
backend/src/db/client.js Normal file
View File

@@ -0,0 +1,10 @@
import postgres from 'postgres'
let sql
export const getDb = () => {
if (!sql) {
sql = postgres(process.env.DATABASE_URL)
}
return sql
}

74
backend/src/db/migrate.js Normal file
View File

@@ -0,0 +1,74 @@
import 'dotenv/config'
import { getDb } from './client.js'
const sql = getDb()
const migrate = async () => {
await sql`
CREATE EXTENSION IF NOT EXISTS "pgcrypto"
`
await sql`
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
permissions JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(255) UNIQUE,
phone VARCHAR(20) UNIQUE,
display_name VARCHAR(100) NOT NULL,
is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS mitras (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(255) UNIQUE,
phone VARCHAR(20) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS control_center_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
role_id UUID NOT NULL REFERENCES roles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS app_config (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('anonymity', '{"enabled": true}')
ON CONFLICT (key) DO NOTHING
`
console.log('Migration complete.')
await sql.end()
}
migrate().catch((err) => {
console.error('Migration failed:', err)
process.exit(1)
})

51
backend/src/db/seed.js Normal file
View File

@@ -0,0 +1,51 @@
import 'dotenv/config'
import admin from 'firebase-admin'
import { getDb } from './client.js'
import { initFirebase } from '../plugins/firebase.js'
const sql = getDb()
const seed = async () => {
initFirebase()
// Create super_admin role
const [role] = await sql`
INSERT INTO roles (name, permissions)
VALUES (
'super_admin',
${sql.json({
mitra: ['create', 'read', 'update', 'delete'],
control_center_users: ['create', 'read', 'update', 'delete'],
config: ['read', 'update'],
roles: ['create', 'read', 'update', 'delete'],
})}
)
ON CONFLICT (name) DO UPDATE SET permissions = EXCLUDED.permissions
RETURNING id
`
// Create first super admin user in Firebase
const email = process.env.SEED_ADMIN_EMAIL || 'admin@halobestie.com'
const password = process.env.SEED_ADMIN_PASSWORD || 'ChangeMe123!'
let firebaseUser
try {
firebaseUser = await admin.auth().getUserByEmail(email)
} catch {
firebaseUser = await admin.auth().createUser({ email, password, displayName: 'Super Admin' })
}
await sql`
INSERT INTO control_center_users (firebase_uid, email, display_name, role_id)
VALUES (${firebaseUser.uid}, ${email}, 'Super Admin', ${role.id})
ON CONFLICT (email) DO NOTHING
`
console.log(`Seed complete. Admin: ${email}`)
await sql.end()
}
seed().catch((err) => {
console.error('Seed failed:', err)
process.exit(1)
})

View File

@@ -0,0 +1,41 @@
import { verifyFirebaseToken } from './firebase.js'
/**
* Fastify preHandler — verifies Firebase JWT and attaches decoded token to request.
* Usage: add as preHandler on any route that requires authentication.
*/
export const authenticate = async (request, reply) => {
const authHeader = request.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return reply.code(401).send({
success: false,
error: { code: 'UNAUTHORIZED', message: 'Missing or invalid authorization header' },
})
}
const token = authHeader.slice(7)
try {
request.firebaseUser = await verifyFirebaseToken(token)
} catch {
return reply.code(401).send({
success: false,
error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' },
})
}
}
/**
* Returns a preHandler that checks if the CC user has the required permission.
* Usage: requirePermission('mitra', 'create')
*/
export const requirePermission = (resource, action) => {
return async (request, reply) => {
const permissions = request.ccUser?.role?.permissions ?? {}
if (!permissions[resource]?.includes(action)) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Insufficient permissions' },
})
}
}
}

View File

@@ -0,0 +1,18 @@
export const errorHandler = (error, request, reply) => {
request.log.error(error)
if (error.validation) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: error.message },
})
}
return reply.code(error.statusCode || 500).send({
success: false,
error: {
code: error.code || 'INTERNAL_ERROR',
message: error.message || 'An unexpected error occurred',
},
})
}

View File

@@ -0,0 +1,19 @@
import admin from 'firebase-admin'
let initialized = false
export const initFirebase = () => {
if (initialized) return
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
}),
})
initialized = true
}
export const verifyFirebaseToken = async (token) => {
return admin.auth().verifyIdToken(token)
}

View File

@@ -0,0 +1,17 @@
import { authenticate } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
export const internalAuthRoutes = async (app) => {
app.post('/verify', { preHandler: authenticate }, async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
if (!user) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Not a control center user' },
})
}
// Attach to request for downstream permission checks
request.ccUser = user
return reply.send({ success: true, data: user })
})
}

View File

@@ -0,0 +1,38 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid, createCcUser, listCcUsers } from '../../services/cc-user.service.js'
const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user
}
export const ccUserRoutes = async (app) => {
app.post('/', {
preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'create')],
}, async (request, reply) => {
const { email, display_name, role_id } = request.body ?? {}
if (!email || !display_name || !role_id) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'email, display_name, and role_id are required' } })
}
// Create Firebase user with temporary password — admin will share credentials verbally
const { initFirebase } = await import('../../plugins/firebase.js')
const admin = (await import('firebase-admin')).default
initFirebase()
const tempPassword = Math.random().toString(36).slice(-10) + 'A1!'
const firebaseUser = await admin.auth().createUser({ email, password: tempPassword })
const user = await createCcUser({ firebase_uid: firebaseUser.uid, email, display_name, role_id })
return reply.code(201).send({ success: true, data: user })
})
app.get('/', {
preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'read')],
}, async (request, reply) => {
const { page = 1, limit = 20 } = request.query
const result = await listCcUsers({ page: Number(page), limit: Number(limit) })
return reply.send({ success: true, data: result })
})
}

View File

@@ -0,0 +1,29 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
import { getAnonymityConfig, setAnonymityConfig } from '../../services/config.service.js'
const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user
}
export const internalConfigRoutes = async (app) => {
app.get('/anonymity', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (request, reply) => {
const config = await getAnonymityConfig()
return reply.send({ success: true, data: config })
})
app.patch('/anonymity', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { anonymity_enabled } = request.body ?? {}
if (typeof anonymity_enabled !== 'boolean') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'anonymity_enabled must be a boolean' } })
}
const config = await setAnonymityConfig(anonymity_enabled)
return reply.send({ success: true, data: config })
})
}

View File

@@ -0,0 +1,45 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
import { createMitra, listMitras, updateMitraStatus } from '../../services/mitra.service.js'
const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user
}
export const mitraManagementRoutes = async (app) => {
app.post('/', {
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'create')],
}, async (request, reply) => {
const { phone, display_name } = request.body ?? {}
if (!phone || !display_name) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'phone and display_name are required' } })
}
const mitra = await createMitra({ phone, display_name })
return reply.code(201).send({ success: true, data: mitra })
})
app.get('/', {
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')],
}, async (request, reply) => {
const { page = 1, limit = 20, is_active } = request.query
const result = await listMitras({
page: Number(page),
limit: Number(limit),
is_active: is_active !== undefined ? is_active === 'true' : undefined,
})
return reply.send({ success: true, data: result })
})
app.patch('/:id/status', {
preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'update')],
}, async (request, reply) => {
const { is_active } = request.body ?? {}
if (typeof is_active !== 'boolean') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'is_active must be a boolean' } })
}
const mitra = await updateMitraStatus(request.params.id, is_active)
return reply.send({ success: true, data: mitra })
})
}

View File

@@ -0,0 +1,18 @@
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js'
import { listRoles } from '../../services/roles.service.js'
const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user
}
export const rolesRoutes = async (app) => {
app.get('/', {
preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'read')],
}, async (request, reply) => {
const roles = await listRoles()
return reply.send({ success: true, data: roles })
})
}

View File

@@ -0,0 +1,15 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerByFirebaseUid } from '../../services/customer.service.js'
export const clientAuthRoutes = async (app) => {
app.post('/verify', { preHandler: authenticate }, async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid)
if (!customer) {
return reply.code(404).send({
success: false,
error: { code: 'NOT_FOUND', message: 'Customer account not found' },
})
}
return reply.send({ success: true, data: customer })
})
}

View File

@@ -0,0 +1,31 @@
import { authenticate } from '../../plugins/auth.js'
import { createAnonymousCustomer, linkCustomerAccount } from '../../services/customer.service.js'
export const customerRoutes = async (app) => {
app.post('/anonymous', async (request, reply) => {
const { display_name } = request.body ?? {}
if (!display_name?.trim()) {
return reply.code(422).send({
success: false,
error: { code: 'DISPLAY_NAME_REQUIRED', message: 'Display name is required' },
})
}
const customer = await createAnonymousCustomer({ display_name: display_name.trim() })
return reply.code(201).send({ success: true, data: customer })
})
app.post('/link', { preHandler: authenticate }, async (request, reply) => {
const { customer_id } = request.body ?? {}
const firebase_uid = request.firebaseUser.uid
if (!customer_id) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'customer_id is required' },
})
}
const customer = await linkCustomerAccount({ customer_id, firebase_uid })
return reply.send({ success: true, data: customer })
})
}

View File

@@ -0,0 +1,44 @@
import { authenticate } from '../../plugins/auth.js'
import { getMitraByFirebaseUid, getMitraByPhone, setMitraFirebaseUid } from '../../services/mitra.service.js'
export const mitraAuthRoutes = async (app) => {
app.post('/verify', { preHandler: authenticate }, async (request, reply) => {
const { uid, phone_number } = request.firebaseUser
// First try lookup by firebase_uid (returning user)
let mitra = await getMitraByFirebaseUid(uid)
// First-time login: link firebase_uid to mitra record via phone number
if (!mitra && phone_number) {
mitra = await getMitraByPhone(phone_number)
if (mitra) {
await setMitraFirebaseUid(mitra.id, uid)
}
}
if (!mitra) {
return reply.code(404).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Account not found. Contact your administrator.' },
})
}
if (!mitra.is_active) {
return reply.code(403).send({
success: false,
error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive. Contact your administrator.' },
})
}
return reply.send({
success: true,
data: {
id: mitra.id,
display_name: mitra.display_name,
phone: mitra.phone,
is_active: mitra.is_active,
created_at: mitra.created_at,
},
})
})
}

View File

@@ -0,0 +1,9 @@
import { authenticate } from '../../plugins/auth.js'
import { getAnonymityConfig } from '../../services/config.service.js'
export const sharedConfigRoutes = async (app) => {
app.get('/anonymity', { preHandler: authenticate }, async (request, reply) => {
const config = await getAnonymityConfig()
return reply.send({ success: true, data: config })
})
}

23
backend/src/server.js Normal file
View File

@@ -0,0 +1,23 @@
import 'dotenv/config'
import { buildPublicApp } from './app.public.js'
import { buildInternalApp } from './app.internal.js'
const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000
const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001
const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1'
const start = async () => {
const publicApp = await buildPublicApp()
const internalApp = await buildInternalApp()
await publicApp.listen({ port: PUBLIC_PORT, host: '0.0.0.0' })
console.log(`Public API listening on port ${PUBLIC_PORT}`)
await internalApp.listen({ port: INTERNAL_PORT, host: INTERNAL_HOST })
console.log(`Internal API listening on ${INTERNAL_HOST}:${INTERNAL_PORT}`)
}
start().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,58 @@
import { getDb } from '../db/client.js'
const sql = getDb()
export const getCcUserByFirebaseUid = async (firebase_uid) => {
const [user] = await sql`
SELECT
u.id, u.email, u.display_name, u.created_at,
r.id as role_id, r.name as role_name, r.permissions
FROM control_center_users u
JOIN roles r ON r.id = u.role_id
WHERE u.firebase_uid = ${firebase_uid}
`
if (!user) return null
return {
id: user.id,
email: user.email,
display_name: user.display_name,
created_at: user.created_at,
role: { id: user.role_id, name: user.role_name, permissions: user.permissions },
}
}
export const createCcUser = async ({ firebase_uid, email, display_name, role_id }) => {
const [user] = await sql`
INSERT INTO control_center_users (firebase_uid, email, display_name, role_id)
VALUES (${firebase_uid}, ${email}, ${display_name}, ${role_id})
RETURNING id, email, display_name, role_id, created_at
`
const [role] = await sql`SELECT id, name FROM roles WHERE id = ${role_id}`
return { ...user, role }
}
export const listCcUsers = async ({ page = 1, limit = 20 }) => {
const offset = (page - 1) * limit
const items = await sql`
SELECT
u.id, u.email, u.display_name, u.created_at,
r.id as role_id, r.name as role_name
FROM control_center_users u
JOIN roles r ON r.id = u.role_id
ORDER BY u.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`
const [{ count }] = await sql`SELECT COUNT(*) FROM control_center_users`
return {
items: items.map((u) => ({
id: u.id,
email: u.email,
display_name: u.display_name,
created_at: u.created_at,
role: { id: u.role_id, name: u.role_name },
})),
total: Number(count),
page,
limit,
}
}

View File

@@ -0,0 +1,17 @@
import { getDb } from '../db/client.js'
const sql = getDb()
export const getAnonymityConfig = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'`
return { anonymity_enabled: row?.value?.enabled ?? true }
}
export const setAnonymityConfig = async (enabled) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('anonymity', ${sql.json({ enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { anonymity_enabled: enabled }
}

View File

@@ -0,0 +1,43 @@
import { getDb } from '../db/client.js'
const sql = getDb()
export const createAnonymousCustomer = async ({ display_name }) => {
const [customer] = await sql`
INSERT INTO customers (display_name, is_anonymous)
VALUES (${display_name}, true)
RETURNING id, display_name, is_anonymous, created_at
`
return customer
}
export const linkCustomerAccount = async ({ customer_id, firebase_uid }) => {
const [existing] = await sql`
SELECT id, firebase_uid FROM customers WHERE id = ${customer_id}
`
if (!existing) throw Object.assign(new Error('Customer not found'), { code: 'NOT_FOUND', statusCode: 404 })
if (existing.firebase_uid) throw Object.assign(new Error('Account already linked'), { code: 'ALREADY_REGISTERED', statusCode: 409 })
// Also fetch phone from firebase_uid if exists in another customer record for uniqueness
const [firebaseLinked] = await sql`
SELECT id FROM customers WHERE firebase_uid = ${firebase_uid}
`
if (firebaseLinked) throw Object.assign(new Error('Account already linked'), { code: 'ALREADY_REGISTERED', statusCode: 409 })
const [updated] = await sql`
UPDATE customers
SET firebase_uid = ${firebase_uid}, is_anonymous = false
WHERE id = ${customer_id}
RETURNING id, display_name, is_anonymous, phone, created_at
`
return updated
}
export const getCustomerByFirebaseUid = async (firebase_uid) => {
const [customer] = await sql`
SELECT id, display_name, is_anonymous, phone, created_at
FROM customers
WHERE firebase_uid = ${firebase_uid}
`
return customer
}

View File

@@ -0,0 +1,63 @@
import { getDb } from '../db/client.js'
const sql = getDb()
export const getMitraByFirebaseUid = async (firebase_uid) => {
const [mitra] = await sql`
SELECT id, display_name, phone, is_active, created_at
FROM mitras
WHERE firebase_uid = ${firebase_uid}
`
return mitra
}
export const getMitraByPhone = async (phone) => {
const [mitra] = await sql`
SELECT id, display_name, phone, is_active, firebase_uid, created_at
FROM mitras
WHERE phone = ${phone}
`
return mitra
}
export const setMitraFirebaseUid = async (id, firebase_uid) => {
await sql`
UPDATE mitras SET firebase_uid = ${firebase_uid} WHERE id = ${id}
`
}
export const createMitra = async ({ phone, display_name }) => {
const [mitra] = await sql`
INSERT INTO mitras (phone, display_name, is_active)
VALUES (${phone}, ${display_name}, false)
RETURNING id, phone, display_name, is_active, created_at
`
return mitra
}
export const updateMitraStatus = async (id, is_active) => {
const [mitra] = await sql`
UPDATE mitras SET is_active = ${is_active}
WHERE id = ${id}
RETURNING id, is_active
`
if (!mitra) throw Object.assign(new Error('Mitra not found'), { code: 'NOT_FOUND', statusCode: 404 })
return mitra
}
export const listMitras = async ({ page = 1, limit = 20, is_active }) => {
const offset = (page - 1) * limit
const conditions = is_active !== undefined
? sql`WHERE is_active = ${is_active}`
: sql``
const items = await sql`
SELECT id, phone, display_name, is_active, created_at
FROM mitras
${conditions}
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
`
const [{ count }] = await sql`SELECT COUNT(*) FROM mitras ${conditions}`
return { items, total: Number(count), page, limit }
}

View File

@@ -0,0 +1,7 @@
import { getDb } from '../db/client.js'
const sql = getDb()
export const listRoles = async () => {
return sql`SELECT id, name, permissions, created_at FROM roles ORDER BY name`
}