🟧 SOLVERA - API & BACKEND DEVELOPER GUIDELINE
🟩 1. TUJUAN GUIDELINE
Dokumen ini berfungsi untuk:
1. Menjadi acuan standar untuk seluruh backend engineer Solvera.
2. Menjamin API konsisten, aman, dan mudah diintegrasikan dengan frontend.
3. Menetapkan arsitektur yang scalable untuk SaaS product (SuperJob, SuperContact, dll).
4. Menetapkan workflow CI/CD, branching, testing, deployment.
🟩 2. OVERALL SYSTEM ARCHITECTURE
Flow kerja platform:
1. Frontend [Link] → mengirim request (REST API / fetch)
2. Backend FastAPI menerima request → validasi → service logic → DB/Odoo
3. Neon PostgreSQL untuk penyimpanan data
4. Odoo Integration Layer untuk master data, finance, atau transaction tertentu
5. Vercel untuk frontend hosting
6. Railway menjalankan full ASGI FastAPI (heavy workloads)
🟩 3. TECH STACK SPECIFICS
🔹 Backend Framework: FastAPI
● Async-friendly
● High performance
● Native OpenAPI docs
● Sangat cocok untuk integrasi Odoo & modern apps
🔹 Database: Neon PostgreSQL
● Serverless PostgreSQL modern
● Connection pooling
● Biasanya 100% cocok untuk FastAPI via SQLAlchemy
🔹 ORM: SQLAlchemy
● Clean model definition
● Mature ecosystem
● Support migrations via Alembic
🔹 Authentication: BetterAuth (NextAuth / JWT)
● Auth dikelola frontend
● Backend hanya melakukan JWT verification
🔹 Deployment Backend: Railway
● Mendukung Gunicorn + Uvicorn worker
● Auto-scaling
● Sangat stabil untuk heavy API
🔹 ERP Layer (optional per project): Odoo
● Integrasi via REST/XML-RPC
● FastAPI bertindak sebagai middleware
🟩 4. BACKEND PROJECT STRUCTURE (SOLVERA
STANDARD)
backend/
app/
api/
v1/
endpoints/
[Link]
[Link]
[Link]
odoo_sync.py
[Link]
core/
[Link]
[Link]
[Link]
db/
[Link]
[Link]
migrations/
models/
[Link]
[Link]
schemas/
user_schema.py
job_schema.py
services/
user_service.py
job_service.py
odoo_service.py
integrations/
odoo_rpc.py
odoo_rest.py
utils/
[Link]
[Link]
tests/
Dockerfile
[Link]
[Link]
.env
Alur:
Endpoint → Service → Repository/Model → DB / Odoo → Response
🟩 5. API DESIGN GUIDELINE (SOLVERA STANDARD)
⭐ Versioning
/api/v1/... wajib digunakan.
⭐ Naming (RESTful)
● List: GET /jobs
● Create: POST /jobs
● Detail: GET /jobs/{id}
● Update: PUT /jobs/{id}
● Delete: DELETE /jobs/{id}
⭐ Response Format (Standard Solvera)
Success
{
"success": true,
"data": {
"id": 1,
"title": "Backend Dev"
},
"error": null
}
Error
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Title is required",
"details": {}
}
}
⭐ Error Codes Solvera
Error Code Description
VALIDATION_ERROR Data request salah
AUTH_REQUIRED Token tidak ditemukan
FORBIDDEN Role tidak cukup
NOT_FOUND Data tidak ditemukan
INTEGRATION_ERRO Error komunikasi dengan Odoo
R
SERVER_ERROR Unexpected
🟩 6. DATA SCHEMA & VALIDATION (PYDANTIC)
Contoh Schema:
class JobCreate(BaseModel):
title: str
description: str
location: str
class JobOut(JobCreate):
id: int
class Config:
orm_mode = True
Semua request wajib melewati schema validation.
🟩 7. AUTHENTICATION FLOW (BETTER AUTH / NEXTAUTH)
Frontend ([Link]) → login → NextAuth menghasilkan JWT session:
Authorization: Bearer <jwt_token>
Backend FastAPI (verifikasi token):
from jose import jwt
def get_current_user(token: str = Depends(oauth2_scheme)):
payload = [Link](token, settings.JWT_SECRET, algorithms=["HS256"])
return UserSession(id=payload["sub"], role=payload["role"])
🟩 8. DATABASE ACCESS LAYER (NEON + SQLALCHEMY)
Startup
engine = create_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Example model
class Job(Base):
__tablename__ = "jobs"
id = Column(Integer, primary_key=True)
title = Column(String)
description = Column(Text)
location = Column(String)
🟩 9. ODOO INTEGRATION (RPC / REST)
RPC (standard Odoo)
import [Link]
url = settings.ODOO_URL
common = [Link](f"{url}/xmlrpc/2/common")
uid = [Link](settings.ODOO_DB, settings.ODOO_USER,
settings.ODOO_PASSWORD, {})
models = [Link](f"{url}/xmlrpc/2/object")
partners = models.execute_kw(
settings.ODOO_DB, uid, settings.ODOO_PASSWORD,
'[Link]', 'search_read',
[[]], {'fields': ['name', 'email']}
)
REST (Modern)
response = [Link](
f"{settings.ODOO_URL}/api/v1/partners",
headers={"Authorization": f"Bearer {settings.ODOO_TOKEN}"}
)
Semua integrasi Odoo diisolasi ke dalam folder:
/integrations/odoo_rpc.py
🟩 10. DEPLOYMENT WORKFLOW — RAILWAY
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY [Link] .
RUN pip install -r [Link]
COPY . .
CMD ["gunicorn", "[Link]:app", "-k", "[Link]",
"--workers", "4", "--bind", "[Link]:8000"]
Deploy
railway login
railway init
railway up
Runtime
● Stable
● No serverless limitation
● Autoscaling available
🟩 11. FRONTEND ↔ BACKEND CONTRACT
Frontend [Link]:
const res = await fetch(`${[Link].NEXT_PUBLIC_API}/jobs`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${[Link]}`
},
body: [Link](form)
})
Backend FastAPI:
● Validasi token
● Proses business logic
● Akses DB / Odoo
● Kirim response JSON standar
🟩 12. GIT WORKFLOW (SOLVERA STANDARD)
Branch Naming
● feature/superjob-create-job
● fix/supercontact-duplicate-contact
● refactor/api-auth-handler
Commit Convention
● feat: add create job endpoint
● fix: handle odoo timeout
● refactor: optimize job service
● chore: update requirements
Merge Request Template
● What changed
● Endpoints updated
● DB migration
● Testing evidence
● Breaking change? (yes/no)
🟩 13. TESTING STANDARD (API & BACKEND)
● Unit test (pytest)
● Integration test (DB + API)
● Postman/Thunder Client testing
● Load testing (Locust)
● Odoo integration testing
Folder:
/tests
test_jobs.py
test_users.py
test_odoo_integration.py
🟩 14. QUALITY & SECURITY CHECKLIST
✔ Security
● JWT validation mandatory
● No exposed sensitive data
● Strict CORS
● Rate limiting for sensitive endpoints
✔ Database
● All models have index
● No nullable field tanpa alasan
● Migration selalu up-to-date
✔ Code Quality
● Async I/O where possible
● Logging structured
● Single-responsibility each service
🗂 Struktur Folder (bagian yang terkait Jobs)
backend/
app/
[Link]
core/
[Link]
[Link]
[Link]
db/
[Link]
[Link]
models/
[Link]
[Link] # hanya untuk contoh auth
schemas/
job_schema.py
user_schema.py
services/
job_service.py
odoo_job_sync.py
integrations/
odoo_client.py
api/
v1/
endpoints/
[Link]
[Link]
[Link]
1️⃣ Konfigurasi Dasar
app/core/[Link]
from pydantic import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "Solvera Backend"
API_V1_PREFIX: str = "/api/v1"
DATABASE_URL: str
JWT_SECRET: str
JWT_ALGORITHM: str = "HS256"
ODOO_URL: str | None = None
ODOO_DB: str | None = None
ODOO_USER: str | None = None
ODOO_PASSWORD: str | None = None
class Config:
env_file = ".env"
settings = Settings()
app/db/[Link]
from sqlalchemy import create_engine
from [Link] import sessionmaker
from [Link] import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
[Link]()
app/db/[Link]
from [Link] import declarative_base
Base = declarative_base()
2️⃣ Model & Schema “Job”
app/models/[Link]
from sqlalchemy import Column, Integer, String, Text, Enum, DateTime, func
from [Link] import Base
import enum
class JobStatus(str, [Link]):
open = "open"
closed = "closed"
draft = "draft"
class Job(Base):
__tablename__ = "jobs"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=False)
location = Column(String(255), nullable=True)
status = Column(Enum(JobStatus), nullable=False, default=[Link])
created_at = Column(DateTime(timezone=True), server_default=[Link]())
updated_at = Column(
DateTime(timezone=True),
server_default=[Link](),
onupdate=[Link](),
)
app/schemas/job_schema.py
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from [Link] import JobStatus
class JobBase(BaseModel):
title: str = Field(..., min_length=3, max_length=255)
description: str = Field(..., min_length=10)
location: Optional[str] = Field(None, max_length=255)
status: JobStatus = [Link]
class JobCreate(JobBase):
pass
class JobUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=3, max_length=255)
description: Optional[str] = Field(None, min_length=10)
location: Optional[str] = Field(None, max_length=255)
status: Optional[JobStatus] = None
class JobOut(JobBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class PaginatedJobs(BaseModel):
items: List[JobOut]
total: int
page: int
limit: int
3️⃣ Security & Auth
app/core/[Link] (verifikasi JWT dari NextAuth/BetterAuth)
from fastapi import HTTPException, status, Depends
from [Link] import OAuth2PasswordBearer
from jose import jwt, JWTError
from pydantic import BaseModel
from [Link] import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # tidak dipakai
langsung
class CurrentUser(BaseModel):
id: int
email: str
role: str = "user"
def get_current_user(token: str = Depends(oauth2_scheme)) -> CurrentUser:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="AUTH_REQUIRED",
)
try:
payload = [Link](token, settings.JWT_SECRET,
algorithms=[settings.JWT_ALGORITHM])
return CurrentUser(
id=[Link]("sub"),
email=[Link]("email"),
role=[Link]("role", "user"),
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="INVALID_TOKEN",
)
4️⃣ Integrasi Odoo (opsional, tapi contoh kita panggil saat
create/update)
app/integrations/odoo_client.py
import [Link]
from [Link] import settings
def get_odoo_client():
if not settings.ODOO_URL:
return None, None, None
common = [Link](f"{settings.ODOO_URL}/xmlrpc/2/common")
uid = [Link](
settings.ODOO_DB,
settings.ODOO_USER,
settings.ODOO_PASSWORD,
{},
)
models = [Link](f"{settings.ODOO_URL}/xmlrpc/2/object")
return common, uid, models
app/services/odoo_job_sync.py
from [Link].odoo_client import get_odoo_client
from [Link] import Job
def sync_job_to_odoo(job: Job):
common, uid, models = get_odoo_client()
if not uid:
# Odoo tidak dikonfigurasi → skip
return
data = {
"name": [Link],
"x_job_location": [Link],
"x_job_status": [Link],
"x_job_description": [Link],
}
# Contoh: model custom di Odoo: x_hr_job
models.execute_kw(
[Link],
uid,
[Link],
"x_hr_job",
"create_or_update_from_api", # misal ada custom method
[data],
)
(Implementasi method di Odoo tergantung modul custom Anda; ini hanya hook.)
5️⃣ Service Layer “job_service”
app/services/job_service.py
from [Link] import Session
from typing import Tuple, List
from [Link] import Job
from [Link].job_schema import JobCreate, JobUpdate
from [Link].odoo_job_sync import sync_job_to_odoo
from fastapi import HTTPException, status
def create_job(db: Session, job_in: JobCreate, current_user_id: int) -> Job:
job = Job(
title=job_in.title,
description=job_in.description,
location=job_in.location,
status=job_in.status,
)
[Link](job)
[Link]()
[Link](job)
# optional sync ke Odoo
sync_job_to_odoo(job)
return job
def get_job(db: Session, job_id: int) -> Job:
job = [Link](Job).filter([Link] == job_id).first()
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="JOB_NOT_FOUND",
)
return job
def list_jobs(
db: Session,
page: int = 1,
limit: int = 20,
status_filter: str | None = None,
) -> Tuple[List[Job], int]:
query = [Link](Job)
if status_filter:
query = [Link]([Link] == status_filter)
total = [Link]()
items = (
query.order_by(Job.created_at.desc())
.offset((page - 1) * limit)
.limit(limit)
.all()
)
return items, total
def update_job(
db: Session,
job_id: int,
job_in: JobUpdate,
current_user_id: int,
) -> Job:
job = get_job(db, job_id)
for field, value in job_in.dict(exclude_unset=True).items():
setattr(job, field, value)
[Link](job)
[Link]()
[Link](job)
sync_job_to_odoo(job)
return job
def delete_job(db: Session, job_id: int, current_user_id: int):
job = get_job(db, job_id)
[Link](job)
[Link]()
return True
6️⃣ Endpoint FastAPI (Router Jobs)
app/api/v1/endpoints/[Link]
from fastapi import APIRouter, Depends, Query, status
from [Link] import Session
from typing import Optional
from [Link] import get_db
from [Link] import get_current_user, CurrentUser
from [Link].job_schema import (
JobCreate,
JobUpdate,
JobOut,
PaginatedJobs,
)
from [Link] import job_service
router = APIRouter(prefix="/jobs", tags=["Jobs"])
@[Link](
"/",
response_model=JobOut,
status_code=status.HTTP_201_CREATED,
)
def create_job_endpoint(
job_in: JobCreate,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user),
):
job = job_service.create_job(db, job_in, current_user.id)
return job
@[Link](
"/",
response_model=PaginatedJobs,
)
def list_jobs_endpoint(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
status_filter: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user),
):
items, total = job_service.list_jobs(db, page=page, limit=limit,
status_filter=status_filter)
return PaginatedJobs(
items=items,
total=total,
page=page,
limit=limit,
)
@[Link](
"/{job_id}",
response_model=JobOut,
)
def get_job_endpoint(
job_id: int,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user),
):
job = job_service.get_job(db, job_id)
return job
@[Link](
"/{job_id}",
response_model=JobOut,
)
def update_job_endpoint(
job_id: int,
job_in: JobUpdate,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user),
):
job = job_service.update_job(db, job_id, job_in, current_user.id)
return job
@[Link](
"/{job_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_job_endpoint(
job_id: int,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user),
):
job_service.delete_job(db, job_id, current_user.id)
return None
7️⃣ API Router & Main
app/api/v1/[Link]
from fastapi import APIRouter
from [Link] import jobs
api_router = APIRouter()
api_router.include_router([Link])
app/[Link]
from fastapi import FastAPI
from [Link] import CORSMiddleware
from [Link] import api_router
from [Link] import settings
app = FastAPI(
title=settings.PROJECT_NAME,
version="1.0.0",
)
# Sesuaikan origins dengan domain [Link] di Vercel
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: ganti ke domain tertentu di production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
@[Link]("/")
def health():
return {"status": "ok", "service": "solvera-backend"}
8️⃣ Contoh Pemanggilan dari [Link] (Frontend)
// contoh di [Link] (App Router)
async function createJob(data: any, token: string) {
const res = await fetch(`${[Link].NEXT_PUBLIC_API_URL}/api/v1/jobs`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: [Link](data),
});
if (![Link]) {
const err = await [Link]();
throw new Error([Link]?.message ?? "Failed to create job");
}
return [Link]();
}
9️⃣ Minimal Migration (Alembic) – Gambaran
Di Alembic, migrasinya kurang lebih:
def upgrade() -> None:
op.create_table(
"jobs",
[Link]("id", [Link], primary_key=True),
[Link]("title", [Link](length=255), nullable=False),
[Link]("description", [Link], nullable=False),
[Link]("location", [Link](length=255), nullable=True),
[Link]("status", [Link]("open", "closed", "draft",
name="jobstatus"), nullable=False),
[Link]("created_at", [Link](timezone=True),
server_default=[Link]()),
[Link]("updated_at", [Link](timezone=True),
server_default=[Link]()),
)