Phase 1 scaffold: auth for all apps

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

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

View File

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

View File

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

View File

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

View File

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

View File

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