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

3
.claude/memory/MEMORY.md Normal file
View File

@@ -0,0 +1,3 @@
# Memory Index
- [Project Overview](project_overview.md) — Halo Bestie: mental health chat platform, full stack decisions and architectural rules

View File

@@ -0,0 +1,45 @@
---
name: Project Overview
description: High-level architecture, stack, and current progress of Halo Bestie
type: project
---
**Halo Bestie** — Mental health chat platform connecting clients with trained mental health professionals (mitra). Paid service with optional trial period.
**Folder structure:**
- `requirement/` — requirement documents (phased)
- `backend/` — Fastify.js API server
- `client_app/` — Flutter app for clients (iOS + Android)
- `mitra_app/` — Flutter app for professionals/mitra (iOS + Android)
- `control_center/` — React + Vite SPA, internal management tool
**Tech stack:**
- Backend: Fastify.js, single codebase, two listeners (public :3000, internal :3001)
- Database: PostgreSQL (GCP Cloud SQL)
- Auth: Firebase Auth — Google/Apple social login + phone OTP (native Flutter UI, no WebView)
- Payment: Xendit
- Mobile: Flutter (iOS + Android)
- Control Center: React + Vite SPA
- Infra: GCP — Cloud Run, Cloud SQL, VPC private networking
**Critical decisions:**
1. One backend serves both mobile apps — routes namespaced `/api/client/`, `/api/mitra/`, `/api/shared/`
2. Control center API is internal-only — Nginx `allow 10.0.0.0/8; deny all` + VPN
3. Firebase JWT verified on every authenticated Fastify route
4. Scale horizontally (Cloud Run) before considering microservices split
5. Real-time/chat features deferred to a future requirements phase
**Phase 1 (Authentication) — fully scaffolded:**
- Backend: two listeners, all routes, services, DB migrations, seed script
- client_app: Flutter BLoC, all auth screens (welcome, display name, register, OTP, force-register, home placeholder)
- mitra_app: Flutter BLoC, OTP-only login, inactive/not-found error handling
- control_center: React + Vite, email/password login, mitra management, user management, anonymity settings
- API contract: `requirement/phase1-api-contract.md`
- Plan: `requirement/phase1-plan.md`
- UI mockup: `requirement/client_app_mockup.html`
**Next:** Phase 2 — sessions, chat, payments. Requirements not yet written.

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npm init:*)",
"Bash(cmd.exe /c \"npm --version\")",
"Bash(flutter --version)"
]
}
}

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
dist/
.env
*.log
.dart_tool/
.packages
build/
*.iml
.idea/
.flutter-plugins
.flutter-plugins-dependencies

49
CLAUDE.md Normal file
View File

@@ -0,0 +1,49 @@
# Halo Bestie — Project Root
Mental health chat platform connecting clients (users seeking support) with trained mental health professionals (mitra). Paid service with optional trial period.
## Folder Structure
| Folder | Purpose |
|---|---|
| `requirement/` | Requirement documents (phased) |
| `backend/` | Fastify.js API server |
| `client_app/` | Flutter app for clients (iOS + Android) |
| `mitra_app/` | Flutter app for professionals/mitra (iOS + Android) |
| `control_center/` | React + Vite SPA — internal management tool |
## Tech Stack
- **Backend:** Fastify.js — single codebase, two listeners
- Public (port 3000) → serves `client_app` and `mitra_app`
- Internal (port 3001) → serves `control_center` only
- **Database:** PostgreSQL (GCP Cloud SQL)
- **Auth:** Firebase Auth — Google/Apple social login + phone OTP (native Flutter UI, no WebView)
- **Payment:** Xendit
- **Mobile:** Flutter (iOS + Android)
- **Control Center:** React + Vite SPA
- **Infra:** GCP — Cloud Run (backend), Cloud SQL (PostgreSQL), VPC private networking
## Key Architectural Decisions
- **One backend** serves both mobile apps — routes namespaced as `/api/client/`, `/api/mitra/`, `/api/shared/`
- **Control center is internal-only** — never expose its API routes to the public internet; protected via Nginx allow/deny + VPN
- **Firebase Auth** tokens are verified on Fastify via JWT — user data lives in PostgreSQL, linked by Firebase UID
- **Horizontal scaling** (Cloud Run) handles load — do not split into microservices prematurely
- **Real-time features** (chat) are deferred to requirements phase
## Current Progress
- **Phase 1 (Authentication)** — fully scaffolded
- Backend routes, services, DB migration + seed script
- client_app: all auth screens (welcome, display name, register, OTP, force-register)
- mitra_app: OTP-only login
- control_center: email/password login, mitra management, user management, anonymity settings
- Docs: `requirement/phase1-plan.md`, `requirement/phase1-api-contract.md`, `requirement/client_app_mockup.html`
- **Phase 2 (Sessions, Chat, Payments)** — not yet started, requirements not yet written
## Domain Concepts
- **Client** — end user seeking mental health support
- **Mitra** — trained mental health professional (partner)
- Sessions are paid; Xendit handles payment and subscription/trial logic

View File

@@ -0,0 +1,3 @@
# Memory Index
- [Backend Context](context.md) — Fastify two listeners, route namespacing, Firebase JWT, PostgreSQL, Xendit, GCP Cloud Run

View 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
View 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
View File

@@ -0,0 +1,3 @@
node_modules/
.env
*.log

45
backend/CLAUDE.md Normal file
View 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
View 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"
}
}

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`
}

View File

@@ -0,0 +1,3 @@
# Memory Index
- [Client App Context](context.md) — Flutter, Firebase Auth native, calls /api/client/ and /api/shared/ only

View File

@@ -0,0 +1,15 @@
---
name: Client App Context
description: Stack, purpose, and API conventions for the Halo Bestie client Flutter app
type: project
---
Flutter mobile app (iOS + Android) for end users seeking mental health support.
**Stack:** Flutter, Firebase Auth (`firebase_auth` + `google_sign_in`), Xendit (payments)
**Auth:** Google Sign-In (native), Apple Sign-In (native), Phone OTP — fully native, no WebView, no Firebase-branded screens. JWT sent as `Authorization: Bearer` on every API call.
**API:** Calls `/api/client/` and `/api/shared/` routes only. Never call `/api/mitra/` or `/internal/`.
**Domain:** Client = user seeking mental health support. Flow: register → match with mitra → book session → chat → pay. Trial period available for new users.

26
client_app/CLAUDE.md Normal file
View File

@@ -0,0 +1,26 @@
# Halo Bestie — Client App
Flutter mobile application for end users (clients) seeking mental health support.
> See root `CLAUDE.md` for full project context and architectural decisions.
## Stack
- **Framework:** Flutter (iOS + Android)
- **Auth:** Firebase Auth — Google Sign-In, Apple Sign-In, Phone OTP
- Fully native UI — no WebView, no Firebase-branded screens
- Use `firebase_auth` + `google_sign_in` packages
- **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes)
- **Payment:** Xendit (paid sessions, optional trial)
## Key Concepts
- Users are **clients** — they seek mental health support ("curhat")
- Core flow: register → browse/match with mitra → book session → chat → pay
- Trial period available for new users
## Conventions
- Never call `/api/mitra/` or `/internal/` routes from this app
- All API calls must include Firebase JWT token in `Authorization` header
- Handle token refresh transparently

View File

@@ -0,0 +1,35 @@
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
class ApiClient {
static const String _baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.halobestie.com',
);
late final Dio _dio;
ApiClient() {
_dio = Dio(BaseOptions(baseUrl: _baseUrl));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
final token = await user.getIdToken();
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
));
}
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
final response = await _dio.post(path, data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
final response = await _dio.get(path, queryParameters: queryParameters);
return response.data as Map<String, dynamic>;
}
}

View File

@@ -0,0 +1,230 @@
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/api_client.dart';
// Events
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
class AppStarted extends AuthEvent {}
class AnonymousLoginRequested extends AuthEvent {
final String displayName;
AnonymousLoginRequested(this.displayName);
@override List<Object?> get props => [displayName];
}
class GoogleLoginRequested extends AuthEvent {}
class AppleLoginRequested extends AuthEvent {}
class PhoneOtpRequested extends AuthEvent {
final String phone;
PhoneOtpRequested(this.phone);
@override List<Object?> get props => [phone];
}
class OtpVerified extends AuthEvent {
final String verificationId;
final String smsCode;
OtpVerified(this.verificationId, this.smsCode);
@override List<Object?> get props => [verificationId, smsCode];
}
class LinkAccountRequested extends AuthEvent {}
class LogoutRequested extends AuthEvent {}
// States
abstract class AuthState extends Equatable {
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final Map<String, dynamic> profile;
AuthAuthenticated(this.profile);
@override List<Object?> get props => [profile];
}
class AuthAnonymous extends AuthState {
final String customerId;
final String displayName;
AuthAnonymous({required this.customerId, required this.displayName});
@override List<Object?> get props => [customerId, displayName];
}
class AuthOtpSent extends AuthState {
final String verificationId;
AuthOtpSent(this.verificationId);
@override List<Object?> get props => [verificationId];
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
@override List<Object?> get props => [message];
}
class AuthForceRegister extends AuthState {
final String customerId;
final String displayName;
AuthForceRegister({required this.customerId, required this.displayName});
@override List<Object?> get props => [customerId, displayName];
}
// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
final _auth = FirebaseAuth.instance;
String? _pendingVerificationId;
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
on<AppStarted>(_onAppStarted);
on<AnonymousLoginRequested>(_onAnonymousLogin);
on<GoogleLoginRequested>(_onGoogleLogin);
on<AppleLoginRequested>(_onAppleLogin);
on<PhoneOtpRequested>(_onPhoneOtpRequested);
on<OtpVerified>(_onOtpVerified);
on<LinkAccountRequested>(_onLinkAccount);
on<LogoutRequested>(_onLogout);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
final displayName = prefs.getString('anonymous_display_name');
if (_auth.currentUser != null) {
await _verifyAndEmit(emit);
} else if (customerId != null && displayName != null) {
// Check anonymity config
try {
final config = await apiClient.get('/api/shared/config/anonymity');
final anonymityEnabled = config['data']['anonymity_enabled'] as bool;
if (!anonymityEnabled) {
emit(AuthForceRegister(customerId: customerId, displayName: displayName));
} else {
emit(AuthAnonymous(customerId: customerId, displayName: displayName));
}
} catch (_) {
emit(AuthAnonymous(customerId: customerId, displayName: displayName));
}
} else {
emit(AuthInitial());
}
}
Future<void> _onAnonymousLogin(AnonymousLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final response = await apiClient.post(
'/api/shared/customer/anonymous',
data: {'display_name': event.displayName},
);
final customer = response['data'] as Map<String, dynamic>;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('anonymous_customer_id', customer['id'] as String);
await prefs.setString('anonymous_display_name', customer['display_name'] as String);
emit(AuthAnonymous(customerId: customer['id'] as String, displayName: customer['display_name'] as String));
} catch (e) {
emit(AuthError('Failed to continue as guest. Please try again.'));
}
}
Future<void> _onGoogleLogin(GoogleLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final googleUser = await GoogleSignIn().signIn();
if (googleUser == null) { emit(AuthInitial()); return; }
final googleAuth = await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await _auth.signInWithCredential(credential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Google sign-in failed. Please try again.'));
}
}
Future<void> _onAppleLogin(AppleLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final appleCredential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
);
final oauthCredential = OAuthProvider('apple.com').credential(
idToken: appleCredential.identityToken,
accessToken: appleCredential.authorizationCode,
);
await _auth.signInWithCredential(oauthCredential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Apple sign-in failed. Please try again.'));
}
}
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
await _auth.verifyPhoneNumber(
phoneNumber: event.phone,
verificationCompleted: (_) {},
verificationFailed: (e) => emit(AuthError('Failed to send OTP. Please try again.')),
codeSent: (verificationId, _) {
_pendingVerificationId = verificationId;
emit(AuthOtpSent(verificationId));
},
codeAutoRetrievalTimeout: (_) {},
);
}
Future<void> _onOtpVerified(OtpVerified event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final credential = PhoneAuthProvider.credential(
verificationId: event.verificationId,
smsCode: event.smsCode,
);
await _auth.signInWithCredential(credential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Invalid OTP. Please try again.'));
}
}
Future<void> _onLinkAccount(LinkAccountRequested event, Emitter<AuthState> emit) async {
// Called after anonymous user completes social/OTP login to link accounts
final prefs = await SharedPreferences.getInstance();
final customerId = prefs.getString('anonymous_customer_id');
if (customerId == null || _auth.currentUser == null) return;
emit(AuthLoading());
try {
await apiClient.post('/api/shared/customer/link', data: {
'customer_id': customerId,
'firebase_uid': _auth.currentUser!.uid,
});
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('Failed to link account. Please try again.'));
}
}
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
await _auth.signOut();
final prefs = await SharedPreferences.getInstance();
await prefs.remove('anonymous_customer_id');
await prefs.remove('anonymous_display_name');
emit(AuthInitial());
}
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
try {
final response = await apiClient.post('/api/client/auth/verify');
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
} catch (e) {
emit(AuthError('Failed to verify account. Please try again.'));
}
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/auth/auth_bloc.dart';
class DisplayNameScreen extends StatefulWidget {
const DisplayNameScreen({super.key});
@override
State<DisplayNameScreen> createState() => _DisplayNameScreenState();
}
class _DisplayNameScreenState extends State<DisplayNameScreen> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final name = _controller.text.trim();
if (name.isEmpty) return;
context.read<AuthBloc>().add(AnonymousLoginRequested(name));
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : _submit,
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Lanjut'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_bloc.dart';
/// Shown when anonymity is disabled by admin.
/// User must link their account. Display name is pre-filled.
class ForceRegisterScreen extends StatefulWidget {
const ForceRegisterScreen({super.key});
@override
State<ForceRegisterScreen> createState() => _ForceRegisterScreenState();
}
class _ForceRegisterScreenState extends State<ForceRegisterScreen> {
final _phoneController = TextEditingController();
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthOtpSent) {
context.push('/auth/otp', extra: _phoneController.text.trim());
}
if (state is AuthAuthenticated) {
// After linking, link account to existing anonymous record
context.read<AuthBloc>().add(LinkAccountRequested());
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Verifikasi Akun')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Untuk melanjutkan, kamu perlu mendaftarkan akun.',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
label: const Text('Lanjut dengan Google'),
),
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
label: const Text('Lanjut dengan Apple'),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/auth/auth_bloc.dart';
class OtpScreen extends StatefulWidget {
final String phone;
const OtpScreen({super.key, required this.phone});
@override
State<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
final _otpController = TextEditingController();
@override
void dispose() {
_otpController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Kode OTP telah dikirim ke ${widget.phone}'),
const SizedBox(height: 24),
TextField(
controller: _otpController,
decoration: const InputDecoration(
labelText: 'Kode OTP',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
maxLength: 6,
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final otp = _otpController.text.trim();
if (otp.length != 6) return;
final verificationId = (state is AuthOtpSent) ? state.verificationId : '';
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Verifikasi'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_bloc.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _phoneController = TextEditingController();
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthOtpSent) {
context.push('/auth/otp', extra: _phoneController.text.trim());
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(GoogleLoginRequested()),
label: const Text('Lanjut dengan Google'),
),
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: state is AuthLoading ? null
: () => context.read<AuthBloc>().add(AppleLoginRequested()),
label: const Text('Lanjut dengan Apple'),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Halo Bestie',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Tempat curhat kamu',
style: TextStyle(fontSize: 16, color: Colors.grey),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
ElevatedButton(
onPressed: () => context.push('/auth/display-name'),
child: const Text('Lanjut sebagai Tamu'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => context.push('/auth/register'),
child: const Text('Daftar / Masuk'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/auth/auth_bloc.dart';
/// Phase 1 placeholder — will be replaced in Phase 2 with chat/session features.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final displayName = state is AuthAuthenticated
? state.profile['display_name'] as String
: state is AuthAnonymous
? state.displayName
: '';
return Scaffold(
appBar: AppBar(
title: const Text('Halo Bestie'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
),
],
),
body: Center(
child: Text(
'Halo, $displayName!',
style: const TextStyle(fontSize: 24),
),
),
);
},
);
}
}

32
client_app/lib/main.dart Normal file
View File

@@ -0,0 +1,32 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/api/api_client.dart';
import 'core/auth/auth_bloc.dart';
import 'firebase_options.dart';
import 'router.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => AuthBloc(apiClient: ApiClient())..add(AppStarted()),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return MaterialApp.router(
title: 'Halo Bestie',
routerConfig: buildRouter(context.read<AuthBloc>()),
);
},
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'core/auth/auth_bloc.dart';
import 'features/auth/screens/welcome_screen.dart';
import 'features/auth/screens/display_name_screen.dart';
import 'features/auth/screens/register_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/auth/screens/force_register_screen.dart';
import 'features/home/home_screen.dart';
GoRouter buildRouter(AuthBloc authBloc) {
return GoRouter(
initialLocation: '/welcome',
redirect: (context, state) {
final authState = authBloc.state;
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
state.matchedLocation == '/welcome';
if (authState is AuthAuthenticated || authState is AuthAnonymous) {
return isAuthRoute ? '/home' : null;
}
if (authState is AuthForceRegister) return '/auth/force-register';
if (!isAuthRoute) return '/welcome';
return null;
},
routes: [
GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()),
GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()),
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
],
);
}

41
client_app/pubspec.yaml Normal file
View File

@@ -0,0 +1,41 @@
name: client_app
description: Halo Bestie - Client App
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# Firebase
firebase_core: ^2.27.1
firebase_auth: ^4.18.0
# Social login
google_sign_in: ^6.2.1
sign_in_with_apple: ^6.1.0
# HTTP
dio: ^5.4.3
# State management
flutter_bloc: ^8.1.5
equatable: ^2.0.5
# Storage
shared_preferences: ^2.2.3
# Navigation
go_router: ^13.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

View File

@@ -0,0 +1,3 @@
# Memory Index
- [Control Center Context](context.md) — React + Vite, internal only, /internal/ routes via VPN, admin role required

View File

@@ -0,0 +1,20 @@
---
name: Control Center Context
description: Stack, security rules, and responsibilities for the Halo Bestie internal control center
type: project
---
React + Vite SPA — internal management tool. **Never expose to public internet.**
**Stack:** React, Vite, Firebase Auth (admin role required)
**API:** Calls internal Fastify listener only (`/internal/` routes, port 3001). Accessed via VPN or private network. Domain: `internal.halobestie.com`.
**Security:**
- Network-level protection: Nginx `allow 10.0.0.0/8; deny all`
- Every API call requires `role: admin` verified server-side
- Do not add any public-facing routes or features here
**Responsibilities:** Approve/manage mitra accounts, platform config, session/payment monitoring, mitra-client escalation management, trial period configuration.
**Why:** Network-level blocking means even an auth bug cannot expose internal routes to the internet.

View File

@@ -0,0 +1,7 @@
# Internal API base URL — accessible via VPN only
VITE_API_BASE_URL=https://internal.halobestie.com
# Firebase
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=

4
control_center/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log

27
control_center/CLAUDE.md Normal file
View File

@@ -0,0 +1,27 @@
# Halo Bestie — Control Center
React + Vite SPA for internal platform management. **Internal use only.**
> See root `CLAUDE.md` for full project context and architectural decisions.
## Stack
- **Framework:** React + Vite
- **Auth:** Firebase Auth (admin role required)
- **API:** Calls internal Fastify listener only (`/internal/` routes on port 3001)
- **Access:** Internal network / VPN only — never exposed to public internet
## Security
- This app and its backend routes must NEVER be accessible from the public internet
- Protected at network level: Nginx `allow 10.0.0.0/8; deny all;`
- Additional role check on every API call (`role: admin`)
- Do not add any public-facing routes or features here
## Key Responsibilities
- Manage and approve mitra accounts
- Configure platform settings
- Monitor sessions and payments
- Manage communication between mitra and client (escalation, disputes)
- Manage trial period configuration

12
control_center/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Halo Bestie Control Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
{
"name": "control-center",
"version": "1.0.0",
"description": "Halo Bestie Control Center",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"firebase": "^10.12.1",
"axios": "^1.7.2",
"@tanstack/react-query": "^5.45.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.3.1"
}
}

View File

@@ -0,0 +1,27 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './core/auth/AuthContext'
import LoginPage from './pages/login/LoginPage'
import MitrasPage from './pages/mitras/MitrasPage'
import UsersPage from './pages/users/UsersPage'
import SettingsPage from './pages/settings/SettingsPage'
import Layout from './components/Layout'
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth()
if (loading) return <div>Loading...</div>
return user ? children : <Navigate to="/login" replace />
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Navigate to="/mitras" replace />} />
<Route path="mitras" element={<MitrasPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
)
}

View File

@@ -0,0 +1,26 @@
import { Outlet, NavLink } from 'react-router-dom'
import { useAuth } from '../core/auth/AuthContext'
export default function Layout() {
const { user, logout } = useAuth()
return (
<div style={{ display: 'flex', minHeight: '100vh' }}>
<nav style={{ width: 220, borderRight: '1px solid #eee', padding: 16 }}>
<h2>Control Center</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li><NavLink to="/mitras">Mitra</NavLink></li>
<li><NavLink to="/users">Users</NavLink></li>
<li><NavLink to="/settings">Settings</NavLink></li>
</ul>
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
<p style={{ fontSize: 12 }}>{user?.email}</p>
<button onClick={logout}>Logout</button>
</div>
</nav>
<main style={{ flex: 1, padding: 24 }}>
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import axios from 'axios'
import { auth } from '../auth/firebase'
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
})
apiClient.interceptors.request.use(async (config) => {
const user = auth.currentUser
if (user) {
const token = await user.getIdToken()
config.headers.Authorization = `Bearer ${token}`
}
return config
})

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { signInWithEmailAndPassword, signOut, onAuthStateChanged } from 'firebase/auth'
import { auth } from './firebase'
import { apiClient } from '../api/api-client'
const AuthContext = createContext(null)
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const unsub = onAuthStateChanged(auth, async (firebaseUser) => {
if (firebaseUser) {
try {
const res = await apiClient.post('/internal/auth/verify')
setUser(res.data.data)
} catch {
await signOut(auth)
setUser(null)
}
} else {
setUser(null)
}
setLoading(false)
})
return unsub
}, [])
const login = (email, password) => signInWithEmailAndPassword(auth, email, password)
const logout = () => signOut(auth)
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)

View File

@@ -0,0 +1,11 @@
import { initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
}
const app = initializeApp(firebaseConfig)
export const auth = getAuth(app)

View File

@@ -0,0 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './core/auth/AuthContext'
import App from './App'
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<App />
</AuthProvider>
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,47 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../core/auth/AuthContext'
export default function LoginPage() {
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
navigate('/')
} catch {
setError('Email atau password salah.')
} finally {
setLoading(false)
}
}
return (
<div style={{ maxWidth: 360, margin: '100px auto', padding: 24 }}>
<h1>Halo Bestie</h1>
<h2>Control Center</h2>
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
</div>
<div>
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={loading} style={{ width: '100%' }}>
{loading ? 'Loading...' : 'Masuk'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,93 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
const fetchMitras = async () => {
const res = await apiClient.get('/internal/mitras')
return res.data.data
}
const createMitra = async (data) => {
const res = await apiClient.post('/internal/mitras', data)
return res.data.data
}
const updateMitraStatus = async ({ id, is_active }) => {
const res = await apiClient.patch(`/internal/mitras/${id}/status`, { is_active })
return res.data.data
}
export default function MitrasPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['mitras'], queryFn: fetchMitras })
const [form, setForm] = useState({ phone: '', display_name: '' })
const [showForm, setShowForm] = useState(false)
const createMutation = useMutation({
mutationFn: createMitra,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mitras'] })
setForm({ phone: '', display_name: '' })
setShowForm(false)
},
})
const statusMutation = useMutation({
mutationFn: updateMitraStatus,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['mitras'] }),
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>Mitra</h1>
<button onClick={() => setShowForm(!showForm)}>+ Tambah Mitra</button>
</div>
{showForm && (
<form onSubmit={(e) => { e.preventDefault(); createMutation.mutate(form) }}
style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
<h3>Tambah Mitra Baru</h3>
<input placeholder="Nomor HP (+628...)" value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
<input placeholder="Nama" value={form.display_name}
onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
<button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
</button>
{createMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</form>
)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nomor HP</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr>
</thead>
<tbody>
{data?.items?.map((mitra) => (
<tr key={mitra.id}>
<td style={{ padding: 8 }}>{mitra.display_name}</td>
<td style={{ padding: 8 }}>{mitra.phone}</td>
<td style={{ padding: 8 }}>{mitra.is_active ? 'Aktif' : 'Nonaktif'}</td>
<td style={{ padding: 8 }}>
<button onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}>
{mitra.is_active ? 'Nonaktifkan' : 'Aktifkan'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
const fetchAnonymityConfig = async () => {
const res = await apiClient.get('/internal/config/anonymity')
return res.data.data
}
const updateAnonymityConfig = async (anonymity_enabled) => {
const res = await apiClient.patch('/internal/config/anonymity', { anonymity_enabled })
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
const mutation = useMutation({
mutationFn: updateAnonymityConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-anonymity'] }),
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<h1>Settings</h1>
<section style={{ marginBottom: 24 }}>
<h2>Anonymity</h2>
<p>Ketika dinonaktifkan, pengguna anonim akan diminta mendaftar setelah sesi selesai.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={data?.anonymity_enabled ?? true}
onChange={e => mutation.mutate(e.target.checked)}
disabled={mutation.isPending}
/>
Izinkan pengguna anonim
</label>
{mutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
const fetchUsers = async () => {
const res = await apiClient.get('/internal/control-center-users')
return res.data.data
}
const fetchRoles = async () => {
const res = await apiClient.get('/internal/roles')
return res.data.data
}
const createUser = async (data) => {
const res = await apiClient.post('/internal/control-center-users', data)
return res.data.data
}
export default function UsersPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['cc-users'], queryFn: fetchUsers })
const { data: roles } = useQuery({ queryKey: ['roles'], queryFn: fetchRoles })
const [form, setForm] = useState({ email: '', display_name: '', role_id: '' })
const [showForm, setShowForm] = useState(false)
const createMutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cc-users'] })
setForm({ email: '', display_name: '', role_id: '' })
setShowForm(false)
},
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>Control Center Users</h1>
<button onClick={() => setShowForm(!showForm)}>+ Tambah User</button>
</div>
{showForm && (
<form onSubmit={(e) => { e.preventDefault(); createMutation.mutate(form) }}
style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
<h3>Tambah User Baru</h3>
<input placeholder="Email" type="email" value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
<input placeholder="Nama" value={form.display_name}
onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
<select value={form.role_id} onChange={e => setForm(f => ({ ...f, role_id: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }}>
<option value="">Pilih Role</option>
{roles?.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
<button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
</button>
{createMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</form>
)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Email</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Role</th>
</tr>
</thead>
<tbody>
{data?.items?.map((user) => (
<tr key={user.id}>
<td style={{ padding: 8 }}>{user.display_name}</td>
<td style={{ padding: 8 }}>{user.email}</td>
<td style={{ padding: 8 }}>{user.role.name}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
})

View File

@@ -0,0 +1,3 @@
# Memory Index
- [Mitra App Context](context.md) — Flutter, Firebase Auth native, calls /api/mitra/ and /api/shared/ only, requires control center approval

View File

@@ -0,0 +1,17 @@
---
name: Mitra App Context
description: Stack, purpose, and API conventions for the Halo Bestie mitra Flutter app
type: project
---
Flutter mobile app (iOS + Android) for trained mental health professionals (mitra/partners).
**Stack:** Flutter, Firebase Auth (`firebase_auth` + `google_sign_in`)
**Auth:** Google Sign-In (native), Apple Sign-In (native), Phone OTP — fully native, no WebView. JWT sent as `Authorization: Bearer` on every API call.
**API:** Calls `/api/mitra/` and `/api/shared/` routes only. Never call `/api/client/` or `/internal/`.
**Domain:** Mitra = trained mental health professional. Flow: register + credential upload → await control center approval → set availability → accept sessions → chat with client → receive payout.
**Important:** Mitra accounts require approval from control center before going live. Mitra role must be verified server-side on every relevant request — never rely on client-side role checks.

25
mitra_app/CLAUDE.md Normal file
View File

@@ -0,0 +1,25 @@
# Halo Bestie — Mitra App
Flutter mobile application for mental health professionals (mitra/partners).
> See root `CLAUDE.md` for full project context and architectural decisions.
## Stack
- **Framework:** Flutter (iOS + Android)
- **Auth:** Firebase Auth — Google Sign-In, Apple Sign-In, Phone OTP
- Fully native UI — no WebView, no Firebase-branded screens
- Use `firebase_auth` + `google_sign_in` packages
- **API:** Calls public Fastify backend (`/api/mitra/` and `/api/shared/` routes)
## Key Concepts
- Users are **mitra** — trained mental health professionals
- Core flow: register + credential verification → set availability → accept sessions → chat with client → receive payment
- Mitra accounts require approval from control center before going live
## Conventions
- Never call `/api/client/` or `/internal/` routes from this app
- All API calls must include Firebase JWT token in `Authorization` header
- Mitra role must be verified server-side on every relevant request

View File

@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
class ApiClient {
static const String _baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.halobestie.com',
);
late final Dio _dio;
ApiClient() {
_dio = Dio(BaseOptions(baseUrl: _baseUrl));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
final token = await user.getIdToken();
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
));
}
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
final response = await _dio.post(path, data: data);
return response.data as Map<String, dynamic>;
}
}

View File

@@ -0,0 +1,115 @@
import 'package:equatable/equatable.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_client.dart';
// Events
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
class AppStarted extends AuthEvent {}
class PhoneOtpRequested extends AuthEvent {
final String phone;
PhoneOtpRequested(this.phone);
@override List<Object?> get props => [phone];
}
class OtpVerified extends AuthEvent {
final String verificationId;
final String smsCode;
OtpVerified(this.verificationId, this.smsCode);
@override List<Object?> get props => [verificationId, smsCode];
}
class LogoutRequested extends AuthEvent {}
// States
abstract class AuthState extends Equatable {
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final Map<String, dynamic> profile;
AuthAuthenticated(this.profile);
@override List<Object?> get props => [profile];
}
class AuthOtpSent extends AuthState {
final String verificationId;
AuthOtpSent(this.verificationId);
@override List<Object?> get props => [verificationId];
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
@override List<Object?> get props => [message];
}
// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
final _auth = FirebaseAuth.instance;
AuthBloc({required this.apiClient}) : super(AuthInitial()) {
on<AppStarted>(_onAppStarted);
on<PhoneOtpRequested>(_onPhoneOtpRequested);
on<OtpVerified>(_onOtpVerified);
on<LogoutRequested>(_onLogout);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
if (_auth.currentUser != null) {
await _verifyAndEmit(emit);
}
}
Future<void> _onPhoneOtpRequested(PhoneOtpRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
await _auth.verifyPhoneNumber(
phoneNumber: event.phone,
verificationCompleted: (_) {},
verificationFailed: (e) => emit(AuthError('Gagal mengirim OTP. Coba lagi.')),
codeSent: (verificationId, _) => emit(AuthOtpSent(verificationId)),
codeAutoRetrievalTimeout: (_) {},
);
}
Future<void> _onOtpVerified(OtpVerified event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final credential = PhoneAuthProvider.credential(
verificationId: event.verificationId,
smsCode: event.smsCode,
);
await _auth.signInWithCredential(credential);
await _verifyAndEmit(emit);
} catch (e) {
emit(AuthError('OTP tidak valid. Coba lagi.'));
}
}
Future<void> _onLogout(LogoutRequested event, Emitter<AuthState> emit) async {
await _auth.signOut();
emit(AuthInitial());
}
Future<void> _verifyAndEmit(Emitter<AuthState> emit) async {
try {
final response = await apiClient.post('/api/mitra/auth/verify');
emit(AuthAuthenticated(response['data'] as Map<String, dynamic>));
} on Exception catch (e) {
await _auth.signOut();
// Surface specific errors from backend
final msg = e.toString();
if (msg.contains('ACCOUNT_NOT_FOUND')) {
emit(AuthError('Akun tidak ditemukan. Hubungi administrator.'));
} else if (msg.contains('ACCOUNT_INACTIVE')) {
emit(AuthError('Akun tidak aktif. Hubungi administrator.'));
} else {
emit(AuthError('Gagal masuk. Coba lagi.'));
}
}
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_bloc.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _phoneController = TextEditingController();
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthOtpSent) {
context.push('/otp', extra: _phoneController.text.trim());
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Halo Bestie Mitra',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
context.read<AuthBloc>().add(PhoneOtpRequested(phone));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Kirim OTP'),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/auth/auth_bloc.dart';
class OtpScreen extends StatefulWidget {
final String phone;
const OtpScreen({super.key, required this.phone});
@override
State<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
final _otpController = TextEditingController();
@override
void dispose() {
_otpController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Kode OTP telah dikirim ke ${widget.phone}'),
const SizedBox(height: 24),
TextField(
controller: _otpController,
decoration: const InputDecoration(
labelText: 'Kode OTP',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
maxLength: 6,
),
const SizedBox(height: 12),
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => ElevatedButton(
onPressed: state is AuthLoading ? null : () {
final otp = _otpController.text.trim();
if (otp.length != 6) return;
final verificationId = state is AuthOtpSent ? state.verificationId : '';
context.read<AuthBloc>().add(OtpVerified(verificationId, otp));
},
child: state is AuthLoading
? const CircularProgressIndicator()
: const Text('Verifikasi'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/auth/auth_bloc.dart';
/// Phase 1 placeholder — will be replaced in Phase 2 with session/chat features.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final displayName = state is AuthAuthenticated
? state.profile['display_name'] as String
: '';
return Scaffold(
appBar: AppBar(
title: const Text('Halo Bestie Mitra'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => context.read<AuthBloc>().add(LogoutRequested()),
),
],
),
body: Center(
child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)),
),
);
},
);
}
}

32
mitra_app/lib/main.dart Normal file
View File

@@ -0,0 +1,32 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/api/api_client.dart';
import 'core/auth/auth_bloc.dart';
import 'firebase_options.dart';
import 'router.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => AuthBloc(apiClient: ApiClient())..add(AppStarted()),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return MaterialApp.router(
title: 'Halo Bestie Mitra',
routerConfig: buildRouter(context.read<AuthBloc>()),
);
},
),
);
}
}

25
mitra_app/lib/router.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'package:go_router/go_router.dart';
import 'core/auth/auth_bloc.dart';
import 'features/auth/screens/login_screen.dart';
import 'features/auth/screens/otp_screen.dart';
import 'features/home/home_screen.dart';
GoRouter buildRouter(AuthBloc authBloc) {
return GoRouter(
initialLocation: '/login',
redirect: (context, state) {
final authState = authBloc.state;
final isAuthRoute = state.matchedLocation.startsWith('/login') ||
state.matchedLocation.startsWith('/otp');
if (authState is AuthAuthenticated) return isAuthRoute ? '/home' : null;
if (!isAuthRoute) return '/login';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
],
);
}

34
mitra_app/pubspec.yaml Normal file
View File

@@ -0,0 +1,34 @@
name: mitra_app
description: Halo Bestie - Mitra App
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# Firebase
firebase_core: ^2.27.1
firebase_auth: ^4.18.0
# HTTP
dio: ^5.4.3
# State management
flutter_bloc: ^8.1.5
equatable: ^2.0.5
# Navigation
go_router: ^13.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

View File

@@ -0,0 +1,3 @@
# Memory Index
- [Requirement Context](context.md) — Phased docs, naming conventions, real-time/chat deferred to future phase

View File

@@ -0,0 +1,13 @@
---
name: Requirement Context
description: Conventions and scope for Halo Bestie requirement documents
type: project
---
Phased requirement documents for all apps.
**Naming convention:** `phase<N>-<domain>.md` (e.g. `phase1-auth.md`, `phase1-payment.md`)
**Each doc must state:** which app(s) it affects (`client_app`, `mitra_app`, `control_center`, `backend`) and any backend route implications.
**Known deferred:** Real-time chat and live features are out of scope for initial phases — planned for a future phase.

17
requirement/CLAUDE.md Normal file
View File

@@ -0,0 +1,17 @@
# Halo Bestie — Requirements
This folder contains requirement documents for all project phases.
> See root `CLAUDE.md` for full project context and architectural decisions.
## Purpose
- Document feature requirements per phase
- Define acceptance criteria
- Capture domain rules and business logic decisions
## Conventions
- Name documents clearly by phase and domain (e.g. `phase1-auth.md`, `phase1-payment.md`)
- Each document should reference which app(s) it affects: `client_app`, `mitra_app`, `control_center`, `backend`
- Real-time / chat features are planned for a future phase

View File

@@ -0,0 +1,511 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Client App Mockup — Halo Bestie</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', sans-serif;
background: #f0f0f0;
padding: 40px 20px;
}
h1 {
text-align: center;
margin-bottom: 8px;
color: #333;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 40px;
font-size: 14px;
}
.screens {
display: flex;
flex-wrap: wrap;
gap: 32px;
justify-content: center;
}
.screen-label {
text-align: center;
font-size: 13px;
font-weight: 600;
color: #555;
margin-bottom: 10px;
}
/* Android phone frame */
.phone {
width: 320px;
height: 640px;
background: #1a1a1a;
border-radius: 40px;
padding: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
position: relative;
}
.phone::before {
content: '';
display: block;
width: 80px;
height: 6px;
background: #333;
border-radius: 3px;
margin: 0 auto 8px;
}
.phone::after {
content: '';
display: block;
width: 40px;
height: 6px;
background: #333;
border-radius: 3px;
margin: 8px auto 0;
}
.screen {
width: 100%;
height: 560px;
background: #fff;
border-radius: 28px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Status bar */
.status-bar {
background: #fff;
padding: 6px 16px;
display: flex;
justify-content: space-between;
font-size: 10px;
color: #333;
font-weight: 600;
}
/* App bar */
.app-bar {
background: #fff;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid #f0f0f0;
}
.app-bar .back-btn {
font-size: 18px;
color: #333;
cursor: pointer;
}
.app-bar .title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
/* Content area */
.content {
flex: 1;
padding: 24px 20px;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Colors */
:root {
--primary: #6C63FF;
--primary-light: #EEF0FF;
--text: #1a1a1a;
--text-light: #888;
--border: #e0e0e0;
--surface: #f8f8f8;
}
/* Components */
.btn-primary {
background: var(--primary);
color: white;
border: none;
border-radius: 12px;
padding: 14px;
font-size: 15px;
font-weight: 600;
width: 100%;
cursor: pointer;
text-align: center;
}
.btn-outline {
background: white;
color: var(--primary);
border: 1.5px solid var(--primary);
border-radius: 12px;
padding: 14px;
font-size: 15px;
font-weight: 600;
width: 100%;
cursor: pointer;
text-align: center;
}
.btn-social {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 12px;
padding: 13px;
font-size: 14px;
font-weight: 500;
width: 100%;
cursor: pointer;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.input-field {
width: 100%;
border: 1.5px solid var(--border);
border-radius: 12px;
padding: 13px 14px;
font-size: 14px;
color: var(--text);
outline: none;
}
.input-label {
font-size: 12px;
font-weight: 600;
color: var(--text-light);
margin-bottom: 6px;
}
.input-group {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.divider-or {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 0;
color: var(--text-light);
font-size: 12px;
}
.divider-or::before, .divider-or::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.big-title {
font-size: 26px;
font-weight: 800;
color: var(--text);
margin-bottom: 6px;
}
.big-subtitle {
font-size: 14px;
color: var(--text-light);
margin-bottom: 36px;
}
.hero-icon {
font-size: 56px;
text-align: center;
margin-bottom: 16px;
}
.tag {
display: inline-block;
background: var(--primary-light);
color: var(--primary);
border-radius: 20px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
margin-bottom: 12px;
}
.otp-boxes {
display: flex;
gap: 8px;
justify-content: center;
margin: 20px 0;
}
.otp-box {
width: 42px;
height: 50px;
border: 2px solid var(--border);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 700;
color: var(--primary);
}
.otp-box.active {
border-color: var(--primary);
}
.helper-text {
font-size: 12px;
color: var(--text-light);
text-align: center;
margin-top: 8px;
}
.banner {
background: #FFF3E0;
border: 1px solid #FFB74D;
border-radius: 12px;
padding: 14px;
font-size: 13px;
color: #E65100;
margin-bottom: 20px;
line-height: 1.5;
}
.home-greeting {
font-size: 22px;
font-weight: 700;
color: var(--text);
margin-bottom: 4px;
}
.home-sub {
font-size: 13px;
color: var(--text-light);
margin-bottom: 28px;
}
.home-card {
background: var(--primary-light);
border-radius: 16px;
padding: 20px;
text-align: center;
color: var(--primary);
font-size: 13px;
font-weight: 500;
}
.home-card .icon { font-size: 32px; margin-bottom: 8px; }
.bottom-nav {
display: flex;
border-top: 1px solid var(--border);
padding: 10px 0 6px;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
font-size: 10px;
color: var(--text-light);
}
.nav-item.active { color: var(--primary); }
.nav-item .icon { font-size: 20px; }
</style>
</head>
<body>
<h1>Halo Bestie — Client App</h1>
<p class="subtitle">Phase 1 · Android Screen Mockups</p>
<div class="screens">
<!-- 1. Welcome -->
<div>
<div class="screen-label">1. Welcome</div>
<div class="phone">
<div class="screen">
<div class="status-bar"><span>9:41</span><span>● ● ▲</span></div>
<div class="content" style="justify-content: center; align-items: center; text-align: center;">
<div class="hero-icon">💬</div>
<div class="big-title">Halo Bestie</div>
<div class="big-subtitle">Tempat curhat kamu</div>
<div style="width: 100%; margin-top: 8px; display: flex; flex-direction: column; gap: 12px;">
<div class="btn-primary">Lanjut sebagai Tamu</div>
<div class="btn-outline">Daftar / Masuk</div>
</div>
</div>
</div>
</div>
</div>
<!-- 2. Pick Display Name -->
<div>
<div class="screen-label">2. Pick Display Name</div>
<div class="phone">
<div class="screen">
<div class="status-bar"><span>9:41</span><span>● ● ▲</span></div>
<div class="app-bar">
<span class="back-btn"></span>
<span class="title">Siapa namamu?</span>
</div>
<div class="content">
<div class="tag">Tamu</div>
<div style="font-size: 20px; font-weight: 700; color: var(--text); margin-bottom: 8px;">Pilih nama panggilanmu</div>
<div style="font-size: 13px; color: var(--text-light); margin-bottom: 28px; line-height: 1.6;">
Nama ini tidak akan terlihat oleh siapapun selain mitra kamu. Kamu bisa pakai nama samaran.
</div>
<div class="input-group">
<div class="input-label">NAMA PANGGILAN</div>
<input class="input-field" placeholder="contoh: Angin Malam" value="Angin Malam" />
</div>
<div style="margin-top: auto;">
<div class="btn-primary">Lanjut →</div>
</div>
</div>
</div>
</div>
</div>
<!-- 3. Register -->
<div>
<div class="screen-label">3. Daftar / Masuk</div>
<div class="phone">
<div class="screen">
<div class="status-bar"><span>9:41</span><span>● ● ▲</span></div>
<div class="app-bar">
<span class="back-btn"></span>
<span class="title">Masuk / Daftar</span>
</div>
<div class="content">
<div style="font-size: 20px; font-weight: 700; color: var(--text); margin-bottom: 6px;">Selamat datang</div>
<div style="font-size: 13px; color: var(--text-light); margin-bottom: 24px;">Masuk atau buat akun baru</div>
<div class="btn-social" style="margin-bottom: 10px;">
<span>🔵</span> Lanjut dengan Google
</div>
<div class="btn-social">
<span>🍎</span> Lanjut dengan Apple
</div>
<div class="divider-or">atau</div>
<div class="input-group">
<div class="input-label">NOMOR HP</div>
<input class="input-field" placeholder="+628xxxxxxxxxx" />
</div>
<div class="btn-primary">Kirim OTP</div>
</div>
</div>
</div>
</div>
<!-- 4. OTP -->
<div>
<div class="screen-label">4. Verifikasi OTP</div>
<div class="phone">
<div class="screen">
<div class="status-bar"><span>9:41</span><span>● ● ▲</span></div>
<div class="app-bar">
<span class="back-btn"></span>
<span class="title">Masukkan OTP</span>
</div>
<div class="content">
<div style="font-size: 20px; font-weight: 700; color: var(--text); margin-bottom: 8px;">Cek SMS kamu</div>
<div style="font-size: 13px; color: var(--text-light); margin-bottom: 28px; line-height: 1.6;">
Kode OTP telah dikirim ke<br/><strong style="color: var(--text);">+628123456789</strong>
</div>
<div class="otp-boxes">
<div class="otp-box">3</div>
<div class="otp-box">8</div>
<div class="otp-box">4</div>
<div class="otp-box active"></div>
<div class="otp-box"></div>
<div class="otp-box"></div>
</div>
<div class="helper-text">Kirim ulang dalam 00:47</div>
<div style="margin-top: auto;">
<div class="btn-primary">Verifikasi</div>
</div>
</div>
</div>
</div>
</div>
<!-- 5. Force Register Wall -->
<div>
<div class="screen-label">5. Force Register Wall</div>
<div class="phone">
<div class="screen">
<div class="status-bar"><span>9:41</span><span>● ● ▲</span></div>
<div class="app-bar">
<span class="title">Verifikasi Akun</span>
</div>
<div class="content">
<div class="banner">
⚠️ Untuk melanjutkan, kamu perlu mendaftarkan akun. Ini hanya memakan waktu sebentar.
</div>
<div style="font-size: 15px; font-weight: 600; color: var(--text); margin-bottom: 16px;">Pilih cara daftar</div>
<div class="btn-social" style="margin-bottom: 10px;">
<span>🔵</span> Lanjut dengan Google
</div>
<div class="btn-social">
<span>🍎</span> Lanjut dengan Apple
</div>
<div class="divider-or">atau</div>
<div class="input-group">
<div class="input-label">NOMOR HP</div>
<input class="input-field" placeholder="+628xxxxxxxxxx" />
</div>
<div class="btn-primary">Kirim OTP</div>
</div>
</div>
</div>
</div>
<!-- 6. Home (Phase 1 placeholder) -->
<div>
<div class="screen-label">6. Home (placeholder)</div>
<div class="phone">
<div class="screen">
<div class="status-bar"><span>9:41</span><span>● ● ▲</span></div>
<div class="app-bar">
<span class="title" style="flex:1;">Halo Bestie</span>
<span style="font-size: 20px; color: var(--text-light);">🔔</span>
</div>
<div class="content">
<div class="home-greeting">Halo, Angin Malam 👋</div>
<div class="home-sub">Semoga harimu menyenangkan</div>
<div class="home-card">
<div class="icon">💜</div>
<div style="font-size: 15px; font-weight: 700; color: var(--primary); margin-bottom: 6px;">Fitur segera hadir</div>
<div>Sesi curhat dengan mitra profesional akan tersedia di Phase 2.</div>
</div>
</div>
<div class="bottom-nav">
<div class="nav-item active"><span class="icon">🏠</span>Beranda</div>
<div class="nav-item"><span class="icon">💬</span>Sesi</div>
<div class="nav-item"><span class="icon">👤</span>Profil</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,452 @@
# Phase 1 — API Contract
## General Conventions
- Base URL public: `https://api.halobestie.com`
- Base URL internal: `https://internal.halobestie.com`
- All requests: `Content-Type: application/json`
- All authenticated requests: `Authorization: Bearer <firebase_jwt>`
- All responses follow this envelope:
```json
// Success
{
"success": true,
"data": { ... }
}
// Error
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human readable message"
}
}
```
---
## Error Codes
| Code | HTTP | Description |
|---|---|---|
| `UNAUTHORIZED` | 401 | Missing or invalid Firebase JWT |
| `FORBIDDEN` | 403 | Valid token but insufficient role/permission |
| `NOT_FOUND` | 404 | Resource not found |
| `VALIDATION_ERROR` | 422 | Request body/params failed validation |
| `ACCOUNT_NOT_FOUND` | 404 | Phone number not registered as mitra |
| `ACCOUNT_INACTIVE` | 403 | Mitra account is inactive |
| `DISPLAY_NAME_REQUIRED` | 422 | Display name not provided |
| `ALREADY_REGISTERED` | 409 | Anonymous user already has phone/social linked |
| `INTERNAL_ERROR` | 500 | Unexpected server error |
---
## Public API (port 3000)
### Customer
#### `POST /api/shared/customer/anonymous`
Create an anonymous customer with a display name.
**Auth:** None
**Request:**
```json
{
"display_name": "Angin Malam"
}
```
**Response `201`:**
```json
{
"success": true,
"data": {
"id": "uuid",
"display_name": "Angin Malam",
"is_anonymous": true,
"created_at": "2026-04-04T00:00:00Z"
}
}
```
**Errors:** `DISPLAY_NAME_REQUIRED`, `VALIDATION_ERROR`
---
#### `POST /api/shared/customer/link`
Link phone OTP or social login to an existing anonymous customer.
**Auth:** Firebase JWT (anonymous customer)
**Request:**
```json
{
"customer_id": "uuid",
"firebase_uid": "firebase_uid_from_auth"
}
```
**Response `200`:**
```json
{
"success": true,
"data": {
"id": "uuid",
"display_name": "Angin Malam",
"is_anonymous": false,
"phone": "+628xxxxxxxxxx",
"created_at": "2026-04-04T00:00:00Z"
}
}
```
**Errors:** `UNAUTHORIZED`, `NOT_FOUND`, `ALREADY_REGISTERED`
---
#### `POST /api/client/auth/verify`
Verify Firebase JWT and return customer profile. Called after every login.
**Auth:** Firebase JWT (customer)
**Request:** _(empty body)_
**Response `200`:**
```json
{
"success": true,
"data": {
"id": "uuid",
"display_name": "Angin Malam",
"is_anonymous": false,
"phone": "+628xxxxxxxxxx",
"created_at": "2026-04-04T00:00:00Z"
}
}
```
**Errors:** `UNAUTHORIZED`, `NOT_FOUND`
---
### Mitra
#### `POST /api/mitra/auth/verify`
Verify Firebase JWT and return mitra profile. Called after OTP login.
**Auth:** Firebase JWT (mitra)
**Request:** _(empty body)_
**Response `200`:**
```json
{
"success": true,
"data": {
"id": "uuid",
"display_name": "Dr. Budi",
"phone": "+628xxxxxxxxxx",
"is_active": true,
"created_at": "2026-04-04T00:00:00Z"
}
}
```
**Errors:** `UNAUTHORIZED`, `ACCOUNT_NOT_FOUND`, `ACCOUNT_INACTIVE`
---
### Anonymity Config (read-only for apps)
#### `GET /api/shared/config/anonymity`
Get current anonymity setting. Apps poll this to decide whether to show force-register wall.
**Auth:** Firebase JWT (customer)
**Response `200`:**
```json
{
"success": true,
"data": {
"anonymity_enabled": true
}
}
```
---
## Internal API (port 3001)
> All internal routes require a valid Firebase JWT with `role` verified server-side.
### Auth
#### `POST /internal/auth/verify`
Verify Firebase JWT and return control center user profile with role and permissions.
**Auth:** Firebase JWT (control center user)
**Request:** _(empty body)_
**Response `200`:**
```json
{
"success": true,
"data": {
"id": "uuid",
"email": "admin@halobestie.com",
"display_name": "Admin",
"role": {
"id": "uuid",
"name": "super_admin",
"permissions": {
"mitra": ["create", "read", "update", "delete"],
"control_center_users": ["create", "read", "update", "delete"],
"config": ["read", "update"]
}
}
}
}
```
**Errors:** `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`
---
### Mitra Management
#### `POST /internal/mitras`
Create a new mitra record.
**Auth:** Firebase JWT (control center user, requires `mitra:create` permission)
**Request:**
```json
{
"phone": "+628xxxxxxxxxx",
"display_name": "Dr. Budi"
}
```
**Response `201`:**
```json
{
"success": true,
"data": {
"id": "uuid",
"phone": "+628xxxxxxxxxx",
"display_name": "Dr. Budi",
"is_active": false,
"created_at": "2026-04-04T00:00:00Z"
}
}
```
**Errors:** `UNAUTHORIZED`, `FORBIDDEN`, `VALIDATION_ERROR`
---
#### `GET /internal/mitras`
List all mitras.
**Auth:** Firebase JWT (control center user, requires `mitra:read` permission)
**Query params:**
- `page` (default: 1)
- `limit` (default: 20)
- `is_active` (optional: `true` | `false`)
**Response `200`:**
```json
{
"success": true,
"data": {
"items": [
{
"id": "uuid",
"phone": "+628xxxxxxxxxx",
"display_name": "Dr. Budi",
"is_active": true,
"created_at": "2026-04-04T00:00:00Z"
}
],
"total": 1,
"page": 1,
"limit": 20
}
}
```
---
#### `PATCH /internal/mitras/:id/status`
Activate or deactivate a mitra.
**Auth:** Firebase JWT (control center user, requires `mitra:update` permission)
**Request:**
```json
{
"is_active": true
}
```
**Response `200`:**
```json
{
"success": true,
"data": {
"id": "uuid",
"is_active": true
}
}
```
**Errors:** `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `VALIDATION_ERROR`
---
### Control Center User Management
#### `POST /internal/control-center-users`
Create a new control center user.
**Auth:** Firebase JWT (requires `control_center_users:create` permission)
**Request:**
```json
{
"email": "operator@halobestie.com",
"display_name": "Operator 1",
"role_id": "uuid"
}
```
**Response `201`:**
```json
{
"success": true,
"data": {
"id": "uuid",
"email": "operator@halobestie.com",
"display_name": "Operator 1",
"role": {
"id": "uuid",
"name": "operator"
},
"created_at": "2026-04-04T00:00:00Z"
}
}
```
**Errors:** `UNAUTHORIZED`, `FORBIDDEN`, `VALIDATION_ERROR`
---
#### `GET /internal/control-center-users`
List all control center users.
**Auth:** Firebase JWT (requires `control_center_users:read` permission)
**Query params:**
- `page` (default: 1)
- `limit` (default: 20)
**Response `200`:**
```json
{
"success": true,
"data": {
"items": [
{
"id": "uuid",
"email": "operator@halobestie.com",
"display_name": "Operator 1",
"role": {
"id": "uuid",
"name": "operator"
},
"created_at": "2026-04-04T00:00:00Z"
}
],
"total": 1,
"page": 1,
"limit": 20
}
}
```
---
### Roles
#### `GET /internal/roles`
List all roles.
**Auth:** Firebase JWT (requires `control_center_users:read` permission)
**Response `200`:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "super_admin",
"permissions": {
"mitra": ["create", "read", "update", "delete"],
"control_center_users": ["create", "read", "update", "delete"],
"config": ["read", "update"]
}
}
]
}
```
---
### Config
#### `GET /internal/config/anonymity`
Get anonymity setting.
**Auth:** Firebase JWT (requires `config:read` permission)
**Response `200`:**
```json
{
"success": true,
"data": {
"anonymity_enabled": true
}
}
```
---
#### `PATCH /internal/config/anonymity`
Toggle anonymity setting.
**Auth:** Firebase JWT (requires `config:update` permission)
**Request:**
```json
{
"anonymity_enabled": false
}
```
**Response `200`:**
```json
{
"success": true,
"data": {
"anonymity_enabled": false
}
}
```
**Errors:** `UNAUTHORIZED`, `FORBIDDEN`, `VALIDATION_ERROR`

119
requirement/phase1-plan.md Normal file
View File

@@ -0,0 +1,119 @@
# Phase 1 Plan — Authentication
## Overview
Three separate auth flows across three apps, backed by one Fastify backend, Firebase Auth, and PostgreSQL.
---
## Database Schema
### `customers`
| Column | Type | Notes |
|---|---|---|
| `id` | UUID PK | |
| `firebase_uid` | VARCHAR | null if anonymous |
| `phone` | VARCHAR | null if anonymous |
| `display_name` | VARCHAR | user-chosen, never from social |
| `is_anonymous` | BOOLEAN | true until phone/social linked |
| `created_at` | TIMESTAMP | |
### `mitras`
| Column | Type | Notes |
|---|---|---|
| `id` | UUID PK | |
| `firebase_uid` | VARCHAR | set on first login |
| `phone` | VARCHAR | primary identifier |
| `display_name` | VARCHAR | |
| `is_active` | BOOLEAN | toggled by control center |
| `created_at` | TIMESTAMP | |
### `control_center_users`
| Column | Type | Notes |
|---|---|---|
| `id` | UUID PK | |
| `firebase_uid` | VARCHAR | |
| `email` | VARCHAR | |
| `display_name` | VARCHAR | |
| `role_id` | FK → `roles` | |
| `created_at` | TIMESTAMP | |
### `roles`
| Column | Type | Notes |
|---|---|---|
| `id` | UUID PK | |
| `name` | VARCHAR | e.g. `super_admin`, `operator` |
| `permissions` | JSONB | flexible permissions object |
| `created_at` | TIMESTAMP | |
---
## Backend (`/backend`)
### Public routes (port 3000)
- `POST /api/shared/customer/anonymous` — create anonymous customer with display name
- `POST /api/shared/customer/link` — link phone/social to existing anonymous customer
- `POST /api/client/auth/verify` — verify Firebase JWT, return customer profile
- `POST /api/mitra/auth/verify` — verify Firebase JWT, return mitra profile
### Internal routes (port 3001)
- `POST /internal/mitras` — create mitra record
- `PATCH /internal/mitras/:id/status` — activate/deactivate mitra
- `POST /internal/control-center-users` — create control center user
- `GET /internal/control-center-users` — list users
- `POST /internal/auth/verify` — verify Firebase JWT, return CC user + role + permissions
- `GET /internal/config/anonymity` — get anonymity setting
- `PATCH /internal/config/anonymity` — toggle anonymity on/off
---
## client_app (`/client_app`)
**Screens:**
1. **Welcome** — "Continue as Guest" or "Register"
2. **Pick Display Name** — shown to all users (anonymous and registering)
3. **Register** — phone OTP or social login (Google/Apple)
4. **Force Register Wall** — shown after session ends if anonymity is disabled; display name pre-filled
**Firebase Auth flows:**
- Phone OTP via `firebase_auth`
- Google Sign-In via `google_sign_in`
- Apple Sign-In via `sign_in_with_apple`
---
## mitra_app (`/mitra_app`)
**Screens:**
1. **Login** — phone number input
2. **OTP Verification**
3. **Home** (post-login, Phase 1 placeholder)
**Notes:**
- No self-register screen — login only
- If phone not found in `mitras` table → show error "Account not found. Contact your administrator."
- If mitra `is_active = false` → show error "Account is inactive. Contact your administrator."
---
## control_center (`/control_center`)
**Screens:**
1. **Login** — email + password (Firebase Auth)
2. **Mitra Management** — create mitra, toggle active/inactive
3. **Control Center User Management** — create users, assign roles
4. **Settings** — toggle anonymity on/off
---
## Seed Script
- Creates first `super_admin` role with full permissions
- Creates first control center user (email + password via Firebase Auth + DB record)
---
## Out of Scope for Phase 1
- Mitra onboarding flow (documents, verification)
- Chat / session features
- Payment / trial period
- Real-time features
- Specific role definitions (RBAC scaffolded, roles defined later)

27
requirement/phase1.md Normal file
View File

@@ -0,0 +1,27 @@
# Context for Halo-Bestie Chat App
We are building chat application to help our user to share their feelings (Indonesian called it "curhat") to trainned professional. Our users will be called Customer, while our professional will be called Mitra. This is a paid service with trial period only available when user registering. The service is duration based paid service, configurable through control center. Trial period is configurable via control period, for both duration and availability.
## Phase 1
We build application for both mitra (our professional), and customer for them to communicate via chat. Control center also need to be build to manage communication (re routing, seeing whose live, etc), manage application and managing features. But on this phase, we want to start from authentication only for both Mitra, Customer and Control Center user. The functionality needed for this phase:
1. Customer can do self register or go as anonymous
2. Both anonymous and registered user can decide who they want to be called. If social login is used, system NEVER used their social name
3. Anonymity of the user can be enabled or disabled through control center
4. When the anonymity is set to disable, anonymous user will be forced to register by either linking to their social login or through phone number OTP
5. Customer registration can be done through mobile number with OTP or social login.
6. Mitra is a user that registered through control center. They wont be able to do self register
7. Mitra primary identification is phone number and logged on through OTP
8. Note that Mitra and Customer can have same number. Because mitra is also human and need to vent their problem as wel :)
9. Control center user is managed by control center admin
10. Mitra on boarding flow, is not yet covered in app. So it is a matter of creating Mitra's data, and set it to active or inactive
## Tech Stack
- mitra app -> flutter for both ios and android
- client_app -> flutter for both ios and android
- backend -> fastify
- payment gateway -> xendit
- auth -> firebase auth
- database -> postgresql

50
requirement/scracthpad.md Normal file
View File

@@ -0,0 +1,50 @@
# Context for Halo-Bestie Chat App
We are building chat application to help our user to share their feelings (Indonesian called it "curhat") to trainned professional. Our users will be called Customer, while our professional will be called Mitra. This is a paid service with trial period only available when user registering. The service is duration based paid service, configurable through control center. Trial period is configurable via control period, for both duration and availability.
## Phase 1
We build application for both mitra (our professional), and customer for them to communicate via chat. Control center also need to be build to manage communication (re routing, seeing whose live, etc), manage application and managing features. It should provide following functionality:
1. Customer can do self register or go as anonymous. But only registered user that never do any transaction can have trial period
2. Anonymity of the user can be enabled or disabled through control center
3. When the anonymity is set to disable, anonymous user will be forced to register by either linking to their social login or through phone number OTP
4. Customer registration can be done through mobile number with OTP or social login.
5. Mitra is a user that registered through control center. They wont be able to do self register
6. Mitra primary identification is phone number and logged on through OTP
7. Note that Mitra and Customer can have same number. Because mitra is also human and need to vent as well.
8. Control center user is managed by control center admin
9. Mitra on boarding flow, is not yet covered in app. So it is a matter of creating Mitra's data, and set it to active or inactive
10. Curhat session is a service that will be bound by time
11. The timing is prepaid, that is sold per x minute based on configuration in control center
12.
### Session Booking Flow
1. Customer click CTA for mulai curhat
2. System will look for available Mitra, and blast them that there is Customer waiting
3. When one of mitra confirm, system will pair Mitra with Customer. Only one Mitra can be paired with Customer
4. Check for trial eligibility
1. If allowed for trial, system will go for next process
2. If not then:
1. system will trigger package selection for payment
2. Customer will select package, and do CTA for payment
3. Once payment confirmed, system will go for next process
5. Curhat session started. Timing must be done in back end
6. When session is over (package time is over) there are 3 cases:
1. Customer want to extend, and mitra is confirmed the extension:
1. User will be triggered for another package selection and payment
2. Customer want to extend, but Mitra is rejecting:
* System will trigger session ending
3. Customer decide to end the session, system will trigger session ending
7. Session ending will request both Mitra and Customer closing message. This message will be stored as closing message from both
## Tech Stack
- mitra app -> flutter for both ios and android
- client_app -> flutter for both ios and android
- backend -> fastify
- payment gateway -> xendit
- auth -> firebase auth
- database -> postgresql