commit a7a2a32d2716d0779210c883ec99b1c899e581ae Author: ramadhan sjamsani Date: Sun Apr 5 10:08:42 2026 +0800 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) diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..a8afdac --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [Project Overview](project_overview.md) — Halo Bestie: mental health chat platform, full stack decisions and architectural rules diff --git a/.claude/memory/project_overview.md b/.claude/memory/project_overview.md new file mode 100644 index 0000000..1268e92 --- /dev/null +++ b/.claude/memory/project_overview.md @@ -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. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..28e494d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(npm init:*)", + "Bash(cmd.exe /c \"npm --version\")", + "Bash(flutter --version)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f77c56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +.env +*.log +.dart_tool/ +.packages +build/ +*.iml +.idea/ +.flutter-plugins +.flutter-plugins-dependencies diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..02cd5c3 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/backend/.claude/memory/MEMORY.md b/backend/.claude/memory/MEMORY.md new file mode 100644 index 0000000..0eb20b7 --- /dev/null +++ b/backend/.claude/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [Backend Context](context.md) — Fastify two listeners, route namespacing, Firebase JWT, PostgreSQL, Xendit, GCP Cloud Run diff --git a/backend/.claude/memory/context.md b/backend/.claude/memory/context.md new file mode 100644 index 0000000..eaf9ece --- /dev/null +++ b/backend/.claude/memory/context.md @@ -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 ` +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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d560d97 --- /dev/null +++ b/backend/.env.example @@ -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" diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..2e8157a --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +*.log diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 100644 index 0000000..8cc1dd7 --- /dev/null +++ b/backend/CLAUDE.md @@ -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 ` 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 diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..24aa0e0 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/app.internal.js b/backend/src/app.internal.js new file mode 100644 index 0000000..8b93e8e --- /dev/null +++ b/backend/src/app.internal.js @@ -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 +} diff --git a/backend/src/app.public.js b/backend/src/app.public.js new file mode 100644 index 0000000..5d3c1b0 --- /dev/null +++ b/backend/src/app.public.js @@ -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 +} diff --git a/backend/src/db/client.js b/backend/src/db/client.js new file mode 100644 index 0000000..e85e416 --- /dev/null +++ b/backend/src/db/client.js @@ -0,0 +1,10 @@ +import postgres from 'postgres' + +let sql + +export const getDb = () => { + if (!sql) { + sql = postgres(process.env.DATABASE_URL) + } + return sql +} diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js new file mode 100644 index 0000000..cc9d868 --- /dev/null +++ b/backend/src/db/migrate.js @@ -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) +}) diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js new file mode 100644 index 0000000..ac6127a --- /dev/null +++ b/backend/src/db/seed.js @@ -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) +}) diff --git a/backend/src/plugins/auth.js b/backend/src/plugins/auth.js new file mode 100644 index 0000000..10d9a88 --- /dev/null +++ b/backend/src/plugins/auth.js @@ -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' }, + }) + } + } +} diff --git a/backend/src/plugins/error-handler.js b/backend/src/plugins/error-handler.js new file mode 100644 index 0000000..8f7c5f7 --- /dev/null +++ b/backend/src/plugins/error-handler.js @@ -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', + }, + }) +} diff --git a/backend/src/plugins/firebase.js b/backend/src/plugins/firebase.js new file mode 100644 index 0000000..5e8bac1 --- /dev/null +++ b/backend/src/plugins/firebase.js @@ -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) +} diff --git a/backend/src/routes/internal/auth.routes.js b/backend/src/routes/internal/auth.routes.js new file mode 100644 index 0000000..49b7fa5 --- /dev/null +++ b/backend/src/routes/internal/auth.routes.js @@ -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 }) + }) +} diff --git a/backend/src/routes/internal/cc-user.routes.js b/backend/src/routes/internal/cc-user.routes.js new file mode 100644 index 0000000..d524222 --- /dev/null +++ b/backend/src/routes/internal/cc-user.routes.js @@ -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 }) + }) +} diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js new file mode 100644 index 0000000..10ea6d4 --- /dev/null +++ b/backend/src/routes/internal/config.routes.js @@ -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 }) + }) +} diff --git a/backend/src/routes/internal/mitra.routes.js b/backend/src/routes/internal/mitra.routes.js new file mode 100644 index 0000000..bde7663 --- /dev/null +++ b/backend/src/routes/internal/mitra.routes.js @@ -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 }) + }) +} diff --git a/backend/src/routes/internal/roles.routes.js b/backend/src/routes/internal/roles.routes.js new file mode 100644 index 0000000..382cfb6 --- /dev/null +++ b/backend/src/routes/internal/roles.routes.js @@ -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 }) + }) +} diff --git a/backend/src/routes/public/client.auth.routes.js b/backend/src/routes/public/client.auth.routes.js new file mode 100644 index 0000000..a4625e2 --- /dev/null +++ b/backend/src/routes/public/client.auth.routes.js @@ -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 }) + }) +} diff --git a/backend/src/routes/public/customer.routes.js b/backend/src/routes/public/customer.routes.js new file mode 100644 index 0000000..1747652 --- /dev/null +++ b/backend/src/routes/public/customer.routes.js @@ -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 }) + }) +} diff --git a/backend/src/routes/public/mitra.auth.routes.js b/backend/src/routes/public/mitra.auth.routes.js new file mode 100644 index 0000000..37e406a --- /dev/null +++ b/backend/src/routes/public/mitra.auth.routes.js @@ -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, + }, + }) + }) +} diff --git a/backend/src/routes/public/shared.config.routes.js b/backend/src/routes/public/shared.config.routes.js new file mode 100644 index 0000000..28bc9c3 --- /dev/null +++ b/backend/src/routes/public/shared.config.routes.js @@ -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 }) + }) +} diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..f4edca7 --- /dev/null +++ b/backend/src/server.js @@ -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) +}) diff --git a/backend/src/services/cc-user.service.js b/backend/src/services/cc-user.service.js new file mode 100644 index 0000000..4e6d193 --- /dev/null +++ b/backend/src/services/cc-user.service.js @@ -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, + } +} diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js new file mode 100644 index 0000000..a8be2e7 --- /dev/null +++ b/backend/src/services/config.service.js @@ -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 } +} diff --git a/backend/src/services/customer.service.js b/backend/src/services/customer.service.js new file mode 100644 index 0000000..ec9f42e --- /dev/null +++ b/backend/src/services/customer.service.js @@ -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 +} diff --git a/backend/src/services/mitra.service.js b/backend/src/services/mitra.service.js new file mode 100644 index 0000000..e5cb20c --- /dev/null +++ b/backend/src/services/mitra.service.js @@ -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 } +} diff --git a/backend/src/services/roles.service.js b/backend/src/services/roles.service.js new file mode 100644 index 0000000..be32ce4 --- /dev/null +++ b/backend/src/services/roles.service.js @@ -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` +} diff --git a/client_app/.claude/memory/MEMORY.md b/client_app/.claude/memory/MEMORY.md new file mode 100644 index 0000000..f8624f1 --- /dev/null +++ b/client_app/.claude/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [Client App Context](context.md) — Flutter, Firebase Auth native, calls /api/client/ and /api/shared/ only diff --git a/client_app/.claude/memory/context.md b/client_app/.claude/memory/context.md new file mode 100644 index 0000000..7c33ed7 --- /dev/null +++ b/client_app/.claude/memory/context.md @@ -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. diff --git a/client_app/CLAUDE.md b/client_app/CLAUDE.md new file mode 100644 index 0000000..d2dec7e --- /dev/null +++ b/client_app/CLAUDE.md @@ -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 diff --git a/client_app/lib/core/api/api_client.dart b/client_app/lib/core/api/api_client.dart new file mode 100644 index 0000000..13dd5e8 --- /dev/null +++ b/client_app/lib/core/api/api_client.dart @@ -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> post(String path, {Map? data}) async { + final response = await _dio.post(path, data: data); + return response.data as Map; + } + + Future> get(String path, {Map? queryParameters}) async { + final response = await _dio.get(path, queryParameters: queryParameters); + return response.data as Map; + } +} diff --git a/client_app/lib/core/auth/auth_bloc.dart b/client_app/lib/core/auth/auth_bloc.dart new file mode 100644 index 0000000..c206748 --- /dev/null +++ b/client_app/lib/core/auth/auth_bloc.dart @@ -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 get props => []; +} + +class AppStarted extends AuthEvent {} +class AnonymousLoginRequested extends AuthEvent { + final String displayName; + AnonymousLoginRequested(this.displayName); + @override List get props => [displayName]; +} +class GoogleLoginRequested extends AuthEvent {} +class AppleLoginRequested extends AuthEvent {} +class PhoneOtpRequested extends AuthEvent { + final String phone; + PhoneOtpRequested(this.phone); + @override List get props => [phone]; +} +class OtpVerified extends AuthEvent { + final String verificationId; + final String smsCode; + OtpVerified(this.verificationId, this.smsCode); + @override List get props => [verificationId, smsCode]; +} +class LinkAccountRequested extends AuthEvent {} +class LogoutRequested extends AuthEvent {} + +// States +abstract class AuthState extends Equatable { + @override + List get props => []; +} + +class AuthInitial extends AuthState {} +class AuthLoading extends AuthState {} +class AuthAuthenticated extends AuthState { + final Map profile; + AuthAuthenticated(this.profile); + @override List get props => [profile]; +} +class AuthAnonymous extends AuthState { + final String customerId; + final String displayName; + AuthAnonymous({required this.customerId, required this.displayName}); + @override List get props => [customerId, displayName]; +} +class AuthOtpSent extends AuthState { + final String verificationId; + AuthOtpSent(this.verificationId); + @override List get props => [verificationId]; +} +class AuthError extends AuthState { + final String message; + AuthError(this.message); + @override List get props => [message]; +} +class AuthForceRegister extends AuthState { + final String customerId; + final String displayName; + AuthForceRegister({required this.customerId, required this.displayName}); + @override List get props => [customerId, displayName]; +} + +// Bloc +class AuthBloc extends Bloc { + final ApiClient apiClient; + final _auth = FirebaseAuth.instance; + String? _pendingVerificationId; + + AuthBloc({required this.apiClient}) : super(AuthInitial()) { + on(_onAppStarted); + on(_onAnonymousLogin); + on(_onGoogleLogin); + on(_onAppleLogin); + on(_onPhoneOtpRequested); + on(_onOtpVerified); + on(_onLinkAccount); + on(_onLogout); + } + + Future _onAppStarted(AppStarted event, Emitter 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 _onAnonymousLogin(AnonymousLoginRequested event, Emitter 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; + 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 _onGoogleLogin(GoogleLoginRequested event, Emitter 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 _onAppleLogin(AppleLoginRequested event, Emitter 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 _onPhoneOtpRequested(PhoneOtpRequested event, Emitter 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 _onOtpVerified(OtpVerified event, Emitter 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 _onLinkAccount(LinkAccountRequested event, Emitter 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 _onLogout(LogoutRequested event, Emitter 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 _verifyAndEmit(Emitter emit) async { + try { + final response = await apiClient.post('/api/client/auth/verify'); + emit(AuthAuthenticated(response['data'] as Map)); + } catch (e) { + emit(AuthError('Failed to verify account. Please try again.')); + } + } +} diff --git a/client_app/lib/features/auth/screens/display_name_screen.dart b/client_app/lib/features/auth/screens/display_name_screen.dart new file mode 100644 index 0000000..eb0ba2d --- /dev/null +++ b/client_app/lib/features/auth/screens/display_name_screen.dart @@ -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 createState() => _DisplayNameScreenState(); +} + +class _DisplayNameScreenState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final name = _controller.text.trim(); + if (name.isEmpty) return; + context.read().add(AnonymousLoginRequested(name)); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + 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( + builder: (context, state) => ElevatedButton( + onPressed: state is AuthLoading ? null : _submit, + child: state is AuthLoading + ? const CircularProgressIndicator() + : const Text('Lanjut'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/auth/screens/force_register_screen.dart b/client_app/lib/features/auth/screens/force_register_screen.dart new file mode 100644 index 0000000..0f16b48 --- /dev/null +++ b/client_app/lib/features/auth/screens/force_register_screen.dart @@ -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 createState() => _ForceRegisterScreenState(); +} + +class _ForceRegisterScreenState extends State { + final _phoneController = TextEditingController(); + + @override + void dispose() { + _phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + 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().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( + builder: (context, state) => ElevatedButton.icon( + icon: const Icon(Icons.g_mobiledata), + onPressed: state is AuthLoading ? null + : () => context.read().add(GoogleLoginRequested()), + label: const Text('Lanjut dengan Google'), + ), + ), + const SizedBox(height: 12), + BlocBuilder( + builder: (context, state) => ElevatedButton.icon( + icon: const Icon(Icons.apple), + onPressed: state is AuthLoading ? null + : () => context.read().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( + builder: (context, state) => ElevatedButton( + onPressed: state is AuthLoading ? null : () { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) return; + context.read().add(PhoneOtpRequested(phone)); + }, + child: state is AuthLoading + ? const CircularProgressIndicator() + : const Text('Kirim OTP'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/auth/screens/otp_screen.dart b/client_app/lib/features/auth/screens/otp_screen.dart new file mode 100644 index 0000000..ac62093 --- /dev/null +++ b/client_app/lib/features/auth/screens/otp_screen.dart @@ -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 createState() => _OtpScreenState(); +} + +class _OtpScreenState extends State { + final _otpController = TextEditingController(); + + @override + void dispose() { + _otpController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + 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( + 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().add(OtpVerified(verificationId, otp)); + }, + child: state is AuthLoading + ? const CircularProgressIndicator() + : const Text('Verifikasi'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart new file mode 100644 index 0000000..09b9ba1 --- /dev/null +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -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 createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + final _phoneController = TextEditingController(); + + @override + void dispose() { + _phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + 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( + builder: (context, state) => ElevatedButton.icon( + icon: const Icon(Icons.g_mobiledata), + onPressed: state is AuthLoading ? null + : () => context.read().add(GoogleLoginRequested()), + label: const Text('Lanjut dengan Google'), + ), + ), + const SizedBox(height: 12), + BlocBuilder( + builder: (context, state) => ElevatedButton.icon( + icon: const Icon(Icons.apple), + onPressed: state is AuthLoading ? null + : () => context.read().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( + builder: (context, state) => ElevatedButton( + onPressed: state is AuthLoading ? null : () { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) return; + context.read().add(PhoneOtpRequested(phone)); + }, + child: state is AuthLoading + ? const CircularProgressIndicator() + : const Text('Kirim OTP'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/auth/screens/welcome_screen.dart b/client_app/lib/features/auth/screens/welcome_screen.dart new file mode 100644 index 0000000..8221033 --- /dev/null +++ b/client_app/lib/features/auth/screens/welcome_screen.dart @@ -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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart new file mode 100644 index 0000000..cfb053d --- /dev/null +++ b/client_app/lib/features/home/home_screen.dart @@ -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( + 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().add(LogoutRequested()), + ), + ], + ), + body: Center( + child: Text( + 'Halo, $displayName!', + style: const TextStyle(fontSize: 24), + ), + ), + ); + }, + ); + } +} diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart new file mode 100644 index 0000000..25e2b5e --- /dev/null +++ b/client_app/lib/main.dart @@ -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( + builder: (context, state) { + return MaterialApp.router( + title: 'Halo Bestie', + routerConfig: buildRouter(context.read()), + ); + }, + ), + ); + } +} diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart new file mode 100644 index 0000000..46265c0 --- /dev/null +++ b/client_app/lib/router.dart @@ -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()), + ], + ); +} diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml new file mode 100644 index 0000000..c2d18dc --- /dev/null +++ b/client_app/pubspec.yaml @@ -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 diff --git a/control_center/.claude/memory/MEMORY.md b/control_center/.claude/memory/MEMORY.md new file mode 100644 index 0000000..3df9812 --- /dev/null +++ b/control_center/.claude/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [Control Center Context](context.md) — React + Vite, internal only, /internal/ routes via VPN, admin role required diff --git a/control_center/.claude/memory/context.md b/control_center/.claude/memory/context.md new file mode 100644 index 0000000..ed14426 --- /dev/null +++ b/control_center/.claude/memory/context.md @@ -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. diff --git a/control_center/.env.example b/control_center/.env.example new file mode 100644 index 0000000..367ea59 --- /dev/null +++ b/control_center/.env.example @@ -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= diff --git a/control_center/.gitignore b/control_center/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/control_center/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/control_center/CLAUDE.md b/control_center/CLAUDE.md new file mode 100644 index 0000000..edb3ef8 --- /dev/null +++ b/control_center/CLAUDE.md @@ -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 diff --git a/control_center/index.html b/control_center/index.html new file mode 100644 index 0000000..e0ae928 --- /dev/null +++ b/control_center/index.html @@ -0,0 +1,12 @@ + + + + + + Halo Bestie Control Center + + +
+ + + diff --git a/control_center/package.json b/control_center/package.json new file mode 100644 index 0000000..5edc661 --- /dev/null +++ b/control_center/package.json @@ -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" + } +} diff --git a/control_center/src/App.jsx b/control_center/src/App.jsx new file mode 100644 index 0000000..545944e --- /dev/null +++ b/control_center/src/App.jsx @@ -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
Loading...
+ return user ? children : +} + +export default function App() { + return ( + + } /> + }> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/control_center/src/components/Layout.jsx b/control_center/src/components/Layout.jsx new file mode 100644 index 0000000..b17f5fc --- /dev/null +++ b/control_center/src/components/Layout.jsx @@ -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 ( +
+ +
+ +
+
+ ) +} diff --git a/control_center/src/core/api/api-client.js b/control_center/src/core/api/api-client.js new file mode 100644 index 0000000..d8077ad --- /dev/null +++ b/control_center/src/core/api/api-client.js @@ -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 +}) diff --git a/control_center/src/core/auth/AuthContext.jsx b/control_center/src/core/auth/AuthContext.jsx new file mode 100644 index 0000000..956407c --- /dev/null +++ b/control_center/src/core/auth/AuthContext.jsx @@ -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 ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/control_center/src/core/auth/firebase.js b/control_center/src/core/auth/firebase.js new file mode 100644 index 0000000..4a156bd --- /dev/null +++ b/control_center/src/core/auth/firebase.js @@ -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) diff --git a/control_center/src/main.jsx b/control_center/src/main.jsx new file mode 100644 index 0000000..6592227 --- /dev/null +++ b/control_center/src/main.jsx @@ -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( + + + + + + + + + +) diff --git a/control_center/src/pages/login/LoginPage.jsx b/control_center/src/pages/login/LoginPage.jsx new file mode 100644 index 0000000..0e2309b --- /dev/null +++ b/control_center/src/pages/login/LoginPage.jsx @@ -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 ( +
+

Halo Bestie

+

Control Center

+
+
+ + setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} /> +
+
+ + setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} /> +
+ {error &&

{error}

} + +
+
+ ) +} diff --git a/control_center/src/pages/mitras/MitrasPage.jsx b/control_center/src/pages/mitras/MitrasPage.jsx new file mode 100644 index 0000000..b7b92f6 --- /dev/null +++ b/control_center/src/pages/mitras/MitrasPage.jsx @@ -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
Loading...
+ + return ( +
+
+

Mitra

+ +
+ + {showForm && ( +
{ e.preventDefault(); createMutation.mutate(form) }} + style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}> +

Tambah Mitra Baru

+ setForm(f => ({ ...f, phone: e.target.value }))} required + style={{ display: 'block', marginBottom: 8, width: '100%' }} /> + setForm(f => ({ ...f, display_name: e.target.value }))} required + style={{ display: 'block', marginBottom: 8, width: '100%' }} /> + + {createMutation.isError &&

Gagal menyimpan.

} +
+ )} + + + + + + + + + + + + {data?.items?.map((mitra) => ( + + + + + + + ))} + +
NamaNomor HPStatusAksi
{mitra.display_name}{mitra.phone}{mitra.is_active ? 'Aktif' : 'Nonaktif'} + +
+
+ ) +} diff --git a/control_center/src/pages/settings/SettingsPage.jsx b/control_center/src/pages/settings/SettingsPage.jsx new file mode 100644 index 0000000..82ec5d3 --- /dev/null +++ b/control_center/src/pages/settings/SettingsPage.jsx @@ -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
Loading...
+ + return ( +
+

Settings

+ +
+

Anonymity

+

Ketika dinonaktifkan, pengguna anonim akan diminta mendaftar setelah sesi selesai.

+ + {mutation.isError &&

Gagal menyimpan.

} +
+
+ ) +} diff --git a/control_center/src/pages/users/UsersPage.jsx b/control_center/src/pages/users/UsersPage.jsx new file mode 100644 index 0000000..693c882 --- /dev/null +++ b/control_center/src/pages/users/UsersPage.jsx @@ -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
Loading...
+ + return ( +
+
+

Control Center Users

+ +
+ + {showForm && ( +
{ e.preventDefault(); createMutation.mutate(form) }} + style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}> +

Tambah User Baru

+ setForm(f => ({ ...f, email: e.target.value }))} required + style={{ display: 'block', marginBottom: 8, width: '100%' }} /> + setForm(f => ({ ...f, display_name: e.target.value }))} required + style={{ display: 'block', marginBottom: 8, width: '100%' }} /> + + + {createMutation.isError &&

Gagal menyimpan.

} +
+ )} + + + + + + + + + + + {data?.items?.map((user) => ( + + + + + + ))} + +
NamaEmailRole
{user.display_name}{user.email}{user.role.name}
+
+ ) +} diff --git a/control_center/vite.config.js b/control_center/vite.config.js new file mode 100644 index 0000000..abccf69 --- /dev/null +++ b/control_center/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, +}) diff --git a/mitra_app/.claude/memory/MEMORY.md b/mitra_app/.claude/memory/MEMORY.md new file mode 100644 index 0000000..1ec40f3 --- /dev/null +++ b/mitra_app/.claude/memory/MEMORY.md @@ -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 diff --git a/mitra_app/.claude/memory/context.md b/mitra_app/.claude/memory/context.md new file mode 100644 index 0000000..05568d9 --- /dev/null +++ b/mitra_app/.claude/memory/context.md @@ -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. diff --git a/mitra_app/CLAUDE.md b/mitra_app/CLAUDE.md new file mode 100644 index 0000000..3bfbb8f --- /dev/null +++ b/mitra_app/CLAUDE.md @@ -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 diff --git a/mitra_app/lib/core/api/api_client.dart b/mitra_app/lib/core/api/api_client.dart new file mode 100644 index 0000000..e0c4404 --- /dev/null +++ b/mitra_app/lib/core/api/api_client.dart @@ -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> post(String path, {Map? data}) async { + final response = await _dio.post(path, data: data); + return response.data as Map; + } +} diff --git a/mitra_app/lib/core/auth/auth_bloc.dart b/mitra_app/lib/core/auth/auth_bloc.dart new file mode 100644 index 0000000..03f02d4 --- /dev/null +++ b/mitra_app/lib/core/auth/auth_bloc.dart @@ -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 get props => []; +} + +class AppStarted extends AuthEvent {} +class PhoneOtpRequested extends AuthEvent { + final String phone; + PhoneOtpRequested(this.phone); + @override List get props => [phone]; +} +class OtpVerified extends AuthEvent { + final String verificationId; + final String smsCode; + OtpVerified(this.verificationId, this.smsCode); + @override List get props => [verificationId, smsCode]; +} +class LogoutRequested extends AuthEvent {} + +// States +abstract class AuthState extends Equatable { + @override + List get props => []; +} + +class AuthInitial extends AuthState {} +class AuthLoading extends AuthState {} +class AuthAuthenticated extends AuthState { + final Map profile; + AuthAuthenticated(this.profile); + @override List get props => [profile]; +} +class AuthOtpSent extends AuthState { + final String verificationId; + AuthOtpSent(this.verificationId); + @override List get props => [verificationId]; +} +class AuthError extends AuthState { + final String message; + AuthError(this.message); + @override List get props => [message]; +} + +// Bloc +class AuthBloc extends Bloc { + final ApiClient apiClient; + final _auth = FirebaseAuth.instance; + + AuthBloc({required this.apiClient}) : super(AuthInitial()) { + on(_onAppStarted); + on(_onPhoneOtpRequested); + on(_onOtpVerified); + on(_onLogout); + } + + Future _onAppStarted(AppStarted event, Emitter emit) async { + if (_auth.currentUser != null) { + await _verifyAndEmit(emit); + } + } + + Future _onPhoneOtpRequested(PhoneOtpRequested event, Emitter 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 _onOtpVerified(OtpVerified event, Emitter 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 _onLogout(LogoutRequested event, Emitter emit) async { + await _auth.signOut(); + emit(AuthInitial()); + } + + Future _verifyAndEmit(Emitter emit) async { + try { + final response = await apiClient.post('/api/mitra/auth/verify'); + emit(AuthAuthenticated(response['data'] as Map)); + } 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.')); + } + } + } +} diff --git a/mitra_app/lib/features/auth/screens/login_screen.dart b/mitra_app/lib/features/auth/screens/login_screen.dart new file mode 100644 index 0000000..7c362f3 --- /dev/null +++ b/mitra_app/lib/features/auth/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _phoneController = TextEditingController(); + + @override + void dispose() { + _phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + 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( + builder: (context, state) => ElevatedButton( + onPressed: state is AuthLoading ? null : () { + final phone = _phoneController.text.trim(); + if (phone.isEmpty) return; + context.read().add(PhoneOtpRequested(phone)); + }, + child: state is AuthLoading + ? const CircularProgressIndicator() + : const Text('Kirim OTP'), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mitra_app/lib/features/auth/screens/otp_screen.dart b/mitra_app/lib/features/auth/screens/otp_screen.dart new file mode 100644 index 0000000..0d38625 --- /dev/null +++ b/mitra_app/lib/features/auth/screens/otp_screen.dart @@ -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 createState() => _OtpScreenState(); +} + +class _OtpScreenState extends State { + final _otpController = TextEditingController(); + + @override + void dispose() { + _otpController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + 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( + 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().add(OtpVerified(verificationId, otp)); + }, + child: state is AuthLoading + ? const CircularProgressIndicator() + : const Text('Verifikasi'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mitra_app/lib/features/home/home_screen.dart b/mitra_app/lib/features/home/home_screen.dart new file mode 100644 index 0000000..2c81ec5 --- /dev/null +++ b/mitra_app/lib/features/home/home_screen.dart @@ -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( + 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().add(LogoutRequested()), + ), + ], + ), + body: Center( + child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24)), + ), + ); + }, + ); + } +} diff --git a/mitra_app/lib/main.dart b/mitra_app/lib/main.dart new file mode 100644 index 0000000..c875a12 --- /dev/null +++ b/mitra_app/lib/main.dart @@ -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( + builder: (context, state) { + return MaterialApp.router( + title: 'Halo Bestie Mitra', + routerConfig: buildRouter(context.read()), + ); + }, + ), + ); + } +} diff --git a/mitra_app/lib/router.dart b/mitra_app/lib/router.dart new file mode 100644 index 0000000..100812b --- /dev/null +++ b/mitra_app/lib/router.dart @@ -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()), + ], + ); +} diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml new file mode 100644 index 0000000..180a1d1 --- /dev/null +++ b/mitra_app/pubspec.yaml @@ -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 diff --git a/requirement/.claude/memory/MEMORY.md b/requirement/.claude/memory/MEMORY.md new file mode 100644 index 0000000..531ff34 --- /dev/null +++ b/requirement/.claude/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [Requirement Context](context.md) — Phased docs, naming conventions, real-time/chat deferred to future phase diff --git a/requirement/.claude/memory/context.md b/requirement/.claude/memory/context.md new file mode 100644 index 0000000..ae0375b --- /dev/null +++ b/requirement/.claude/memory/context.md @@ -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-.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. diff --git a/requirement/CLAUDE.md b/requirement/CLAUDE.md new file mode 100644 index 0000000..1f2e5ef --- /dev/null +++ b/requirement/CLAUDE.md @@ -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 diff --git a/requirement/client_app_mockup.html b/requirement/client_app_mockup.html new file mode 100644 index 0000000..0bf1e3a --- /dev/null +++ b/requirement/client_app_mockup.html @@ -0,0 +1,511 @@ + + + + + Client App Mockup — Halo Bestie + + + + +

Halo Bestie — Client App

+

Phase 1 · Android Screen Mockups

+ +
+ + +
+
1. Welcome
+
+
+
9:41● ● ▲
+
+
💬
+
Halo Bestie
+
Tempat curhat kamu
+
+
Lanjut sebagai Tamu
+
Daftar / Masuk
+
+
+
+
+
+ + +
+
2. Pick Display Name
+
+
+
9:41● ● ▲
+
+ + Siapa namamu? +
+
+
Tamu
+
Pilih nama panggilanmu
+
+ Nama ini tidak akan terlihat oleh siapapun selain mitra kamu. Kamu bisa pakai nama samaran. +
+
+
NAMA PANGGILAN
+ +
+
+
Lanjut →
+
+
+
+
+
+ + +
+
3. Daftar / Masuk
+
+
+
9:41● ● ▲
+
+ + Masuk / Daftar +
+
+
Selamat datang
+
Masuk atau buat akun baru
+ +
+ 🔵 Lanjut dengan Google +
+
+ 🍎 Lanjut dengan Apple +
+ +
atau
+ +
+
NOMOR HP
+ +
+ +
Kirim OTP
+
+
+
+
+ + +
+
4. Verifikasi OTP
+
+
+
9:41● ● ▲
+
+ + Masukkan OTP +
+
+
Cek SMS kamu
+
+ Kode OTP telah dikirim ke
+628123456789 +
+ +
+
3
+
8
+
4
+
+
+
+
+
Kirim ulang dalam 00:47
+ +
+
Verifikasi
+
+
+
+
+
+ + +
+
5. Force Register Wall
+
+
+
9:41● ● ▲
+
+ Verifikasi Akun +
+
+ + +
Pilih cara daftar
+ +
+ 🔵 Lanjut dengan Google +
+
+ 🍎 Lanjut dengan Apple +
+ +
atau
+ +
+
NOMOR HP
+ +
+ +
Kirim OTP
+
+
+
+
+ + +
+
6. Home (placeholder)
+
+
+
9:41● ● ▲
+
+ Halo Bestie + 🔔 +
+
+
Halo, Angin Malam 👋
+
Semoga harimu menyenangkan
+ +
+
💜
+
Fitur segera hadir
+
Sesi curhat dengan mitra profesional akan tersedia di Phase 2.
+
+
+
+ + + +
+
+
+
+ +
+ + diff --git a/requirement/phase1-api-contract.md b/requirement/phase1-api-contract.md new file mode 100644 index 0000000..5e9c7d2 --- /dev/null +++ b/requirement/phase1-api-contract.md @@ -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 ` +- 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` diff --git a/requirement/phase1-plan.md b/requirement/phase1-plan.md new file mode 100644 index 0000000..5d4b2f7 --- /dev/null +++ b/requirement/phase1-plan.md @@ -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) diff --git a/requirement/phase1.md b/requirement/phase1.md new file mode 100644 index 0000000..fdcf6c5 --- /dev/null +++ b/requirement/phase1.md @@ -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 \ No newline at end of file diff --git a/requirement/scracthpad.md b/requirement/scracthpad.md new file mode 100644 index 0000000..561177b --- /dev/null +++ b/requirement/scracthpad.md @@ -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 \ No newline at end of file