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:
3
backend/.claude/memory/MEMORY.md
Normal file
3
backend/.claude/memory/MEMORY.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Memory Index
|
||||
|
||||
- [Backend Context](context.md) — Fastify two listeners, route namespacing, Firebase JWT, PostgreSQL, Xendit, GCP Cloud Run
|
||||
31
backend/.claude/memory/context.md
Normal file
31
backend/.claude/memory/context.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Backend Context
|
||||
description: Stack, two-listener architecture, route conventions, and auth flow for the Halo Bestie backend
|
||||
type: project
|
||||
---
|
||||
|
||||
Fastify.js REST API — single process, two HTTP listeners.
|
||||
|
||||
**Listeners:**
|
||||
- Public `0.0.0.0:3000` → serves `client_app` + `mitra_app`
|
||||
- Internal `private-ip:3001` → serves `control_center` only (never expose publicly)
|
||||
|
||||
**Route namespacing:**
|
||||
- `/api/client/` — client app routes
|
||||
- `/api/mitra/` — mitra app routes
|
||||
- `/api/shared/` — shared routes (auth, lookup, etc.)
|
||||
- `/internal/` — control center routes (internal listener only)
|
||||
|
||||
**Auth flow:**
|
||||
1. Firebase Auth issues JWT on mobile/web
|
||||
2. Client sends `Authorization: Bearer <token>`
|
||||
3. Fastify verifies via Firebase Admin SDK
|
||||
4. User fetched from PostgreSQL by Firebase UID
|
||||
|
||||
**Stack:** Fastify.js, PostgreSQL (GCP Cloud SQL), Firebase Admin SDK, Xendit, GCP Cloud Run
|
||||
|
||||
**Conventions:**
|
||||
- Business logic in `services/` — never directly in route handlers
|
||||
- All routes authenticated unless explicitly marked public
|
||||
- Internal routes require additional `role: admin` check
|
||||
- Do not mix public and internal listener route registrations
|
||||
12
backend/.env.example
Normal file
12
backend/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Server
|
||||
PUBLIC_PORT=3000
|
||||
INTERNAL_PORT=3001
|
||||
INTERNAL_HOST=127.0.0.1
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/halobestie
|
||||
|
||||
# Firebase
|
||||
FIREBASE_PROJECT_ID=your-firebase-project-id
|
||||
FIREBASE_CLIENT_EMAIL=your-service-account@project.iam.gserviceaccount.com
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
3
backend/.gitignore
vendored
Normal file
3
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
45
backend/CLAUDE.md
Normal file
45
backend/CLAUDE.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Halo Bestie — Backend
|
||||
|
||||
Fastify.js REST API serving both mobile apps and the internal control center.
|
||||
|
||||
> See root `CLAUDE.md` for full project context and architectural decisions.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Runtime:** Node.js + Fastify.js
|
||||
- **Database:** PostgreSQL via GCP Cloud SQL
|
||||
- **Auth:** Firebase Auth JWT verification (no session, stateless)
|
||||
- **Payment:** Xendit
|
||||
- **Infra:** GCP Cloud Run
|
||||
|
||||
## Two Listeners
|
||||
|
||||
```
|
||||
Public (0.0.0.0:3000) → client_app + mitra_app routes
|
||||
Internal (private IP:3001) → control_center routes only
|
||||
```
|
||||
|
||||
Internal listener must never be exposed to the public internet.
|
||||
|
||||
## Route Namespacing
|
||||
|
||||
```
|
||||
/api/client/... → client app routes
|
||||
/api/mitra/... → mitra app routes
|
||||
/api/shared/... → shared routes (e.g. auth, lookup)
|
||||
/internal/... → control center routes (internal listener only)
|
||||
```
|
||||
|
||||
## Auth Flow
|
||||
|
||||
1. Firebase Auth issues JWT token on mobile/web
|
||||
2. Client sends JWT in `Authorization: Bearer <token>` header
|
||||
3. Fastify verifies token using Firebase Admin SDK on every request
|
||||
4. User record fetched from PostgreSQL by Firebase UID
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- All routes must be authenticated unless explicitly marked public
|
||||
- Internal routes have an additional role check (`role: admin`)
|
||||
- Use Fastify plugins for shared middleware (auth, error handling, logging)
|
||||
- Business logic lives in `services/` — never directly in route handlers
|
||||
25
backend/package.json
Normal file
25
backend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "halo-bestie-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Halo Bestie backend API",
|
||||
"main": "src/server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"db:migrate": "node src/db/migrate.js",
|
||||
"db:seed": "node src/db/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^4.28.1",
|
||||
"@fastify/sensible": "^5.6.0",
|
||||
"firebase-admin": "^12.2.0",
|
||||
"pg": "^8.12.0",
|
||||
"postgres": "^3.4.4",
|
||||
"zod": "^3.23.8",
|
||||
"dotenv": "^16.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.6"
|
||||
}
|
||||
}
|
||||
23
backend/src/app.internal.js
Normal file
23
backend/src/app.internal.js
Normal 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
21
backend/src/app.public.js
Normal 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
10
backend/src/db/client.js
Normal 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
74
backend/src/db/migrate.js
Normal 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
51
backend/src/db/seed.js
Normal 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)
|
||||
})
|
||||
41
backend/src/plugins/auth.js
Normal file
41
backend/src/plugins/auth.js
Normal 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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
18
backend/src/plugins/error-handler.js
Normal file
18
backend/src/plugins/error-handler.js
Normal 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',
|
||||
},
|
||||
})
|
||||
}
|
||||
19
backend/src/plugins/firebase.js
Normal file
19
backend/src/plugins/firebase.js
Normal 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)
|
||||
}
|
||||
17
backend/src/routes/internal/auth.routes.js
Normal file
17
backend/src/routes/internal/auth.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
38
backend/src/routes/internal/cc-user.routes.js
Normal file
38
backend/src/routes/internal/cc-user.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
29
backend/src/routes/internal/config.routes.js
Normal file
29
backend/src/routes/internal/config.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
45
backend/src/routes/internal/mitra.routes.js
Normal file
45
backend/src/routes/internal/mitra.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
18
backend/src/routes/internal/roles.routes.js
Normal file
18
backend/src/routes/internal/roles.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
15
backend/src/routes/public/client.auth.routes.js
Normal file
15
backend/src/routes/public/client.auth.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
31
backend/src/routes/public/customer.routes.js
Normal file
31
backend/src/routes/public/customer.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
44
backend/src/routes/public/mitra.auth.routes.js
Normal file
44
backend/src/routes/public/mitra.auth.routes.js
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
9
backend/src/routes/public/shared.config.routes.js
Normal file
9
backend/src/routes/public/shared.config.routes.js
Normal 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
23
backend/src/server.js
Normal 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)
|
||||
})
|
||||
58
backend/src/services/cc-user.service.js
Normal file
58
backend/src/services/cc-user.service.js
Normal 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,
|
||||
}
|
||||
}
|
||||
17
backend/src/services/config.service.js
Normal file
17
backend/src/services/config.service.js
Normal 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 }
|
||||
}
|
||||
43
backend/src/services/customer.service.js
Normal file
43
backend/src/services/customer.service.js
Normal 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
|
||||
}
|
||||
63
backend/src/services/mitra.service.js
Normal file
63
backend/src/services/mitra.service.js
Normal 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 }
|
||||
}
|
||||
7
backend/src/services/roles.service.js
Normal file
7
backend/src/services/roles.service.js
Normal 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`
|
||||
}
|
||||
Reference in New Issue
Block a user