Skip to content

ADR-022: CLARISSA Portal - Software Architecture

Status Proposed
Date 2026-01-22
Authors Wolfram Laube, Claude (AI Assistant)
Supersedes -
Related ADR-021 (System & Security Architecture), ADR-020 (Portal Vision)

Context

ADR-021 defines the system and security architecture of the CLARISSA Portal (Cloud Run, OIDC, etc.). This ADR describes the software architecture:

  1. Hexagonal Architecture: Platform-agnostic core with adapters
  2. API Design: Endpoints, Contracts, Error Handling
  3. Data Model: Firestore Collections
  4. Frontend: Alpine.js SPA

Decision

Key Decisions

Decision Choice Rationale
Architecture Pattern Hexagonal (Ports & Adapters) Platform portability, testability
Core Language Python 3.11+ Team-Skills, FastAPI, async
API Framework FastAPI Async, Pydantic, OpenAPI
Frontend Alpine.js + HTMX Lightweight, kein Build-Step
Styling Tailwind CSS Utility-first, schnell

Hexagonal Architecture

Prinzip

The business logic is platform-independent in the shared/ directory. Thin adapters translate between platform (Cloud Run, OpenWhisk) and core.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                         Shared Core (Pure Python)                        โ”‚
โ”‚                         Platform-agnostic Business Logic                 โ”‚
โ”‚                                                                          โ”‚
โ”‚   shared/                                                                โ”‚
โ”‚   โ”œโ”€โ”€ billing/                                                           โ”‚
โ”‚   โ”‚   โ”œโ”€โ”€ pdf_generator.py      # Jinja2 + WeasyPrint                   โ”‚
โ”‚   โ”‚   โ”œโ”€โ”€ invoice_calculator.py # Line items, totals                    โ”‚
โ”‚   โ”‚   โ””โ”€โ”€ models.py             # Pydantic Models                        โ”‚
โ”‚   โ”‚                                                                      โ”‚
โ”‚   โ”œโ”€โ”€ benchmarks/                                                        โ”‚
โ”‚   โ”‚   โ”œโ”€โ”€ aggregator.py         # Stats computation                     โ”‚
โ”‚   โ”‚   โ””โ”€โ”€ anomaly_detector.py   # Deviation detection                   โ”‚
โ”‚   โ”‚                                                                      โ”‚
โ”‚   โ””โ”€โ”€ core/                                                              โ”‚
โ”‚       โ”œโ”€โ”€ retry.py              # Retry strategy                        โ”‚
โ”‚       โ””โ”€โ”€ correlation.py        # Request correlation                   โ”‚
โ”‚                                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
              โ”‚                                 โ”‚
              โ–ผ                                 โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   OpenWhisk Adapter         โ”‚   โ”‚   Cloud Run Adapter         โ”‚
โ”‚                             โ”‚   โ”‚                             โ”‚
โ”‚   def main(args: dict):     โ”‚   โ”‚   @app.post("/worker/...")  โ”‚
โ”‚     result = core.run(...)  โ”‚   โ”‚   async def handler(req):   โ”‚
โ”‚     return {"body": result} โ”‚   โ”‚     return core.run(...)    โ”‚
โ”‚                             โ”‚   โ”‚                             โ”‚
โ”‚   JSON in โ†’ JSON out        โ”‚   โ”‚   HTTP Request/Response     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Vorteile

  • Same Core Code: Business-Logik einmal schreiben, einmal testen
  • Platform Portability: Switch without code changes
  • Benchmarking: Apples-to-apples comparison Cloud Run vs OpenWhisk
  • Testbarkeit: Core ohne HTTP/Container testbar

Project Structure

clarissa-portal/
โ”‚
โ”œโ”€โ”€ shared/                            # โญ Platform-agnostic Core
โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”œโ”€โ”€ core/
โ”‚   โ”‚   โ”œโ”€โ”€ retry.py                   # Retry strategy (tenacity)
โ”‚   โ”‚   โ”œโ”€โ”€ correlation.py             # Correlation ID handling
โ”‚   โ”‚   โ””โ”€โ”€ exceptions.py              # Domain exceptions
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ billing/
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”œโ”€โ”€ pdf_generator.py           # Core: Jinja2 + WeasyPrint
โ”‚   โ”‚   โ”œโ”€โ”€ invoice_calculator.py      # Core: Line items, totals
โ”‚   โ”‚   โ”œโ”€โ”€ models.py                  # Pydantic: Invoice, LineItem
โ”‚   โ”‚   โ””โ”€โ”€ templates/
โ”‚   โ”‚       โ””โ”€โ”€ invoice.html           # Jinja2 Template
โ”‚   โ”‚
โ”‚   โ””โ”€โ”€ benchmarks/
โ”‚       โ”œโ”€โ”€ __init__.py
โ”‚       โ”œโ”€โ”€ aggregator.py              # Core: Stats computation
โ”‚       โ”œโ”€โ”€ anomaly_detector.py        # Core: Deviation detection
โ”‚       โ””โ”€โ”€ models.py                  # Pydantic: BenchmarkResult
โ”‚
โ”œโ”€โ”€ services/                          # Cloud Run Services
โ”‚   โ”œโ”€โ”€ portal-api/
โ”‚   โ”‚   โ”œโ”€โ”€ src/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ main.py                # FastAPI entry
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ config.py              # Settings (Pydantic)
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ core/
โ”‚   โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ auth.py            # OIDC + PKCE
โ”‚   โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ middleware.py      # CORS, Logging, Correlation
โ”‚   โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ dependencies.py    # FastAPI Dependencies
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ api/v1/
โ”‚   โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ auth.py
โ”‚   โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ time.py
โ”‚   โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ billing.py
โ”‚   โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ benchmarks.py
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ services/
โ”‚   โ”‚   โ”‚       โ””โ”€โ”€ worker_client.py   # HTTP client to Worker
โ”‚   โ”‚   โ”œโ”€โ”€ Dockerfile
โ”‚   โ”‚   โ””โ”€โ”€ requirements.txt
โ”‚   โ”‚
โ”‚   โ””โ”€โ”€ worker/
โ”‚       โ”œโ”€โ”€ src/
โ”‚       โ”‚   โ”œโ”€โ”€ main.py
โ”‚       โ”‚   โ”œโ”€โ”€ billing/
โ”‚       โ”‚   โ”‚   โ””โ”€โ”€ generate_pdf.py    # Adapter โ†’ shared/billing
โ”‚       โ”‚   โ””โ”€โ”€ benchmarks/
โ”‚       โ”‚       โ””โ”€โ”€ aggregate.py       # Adapter โ†’ shared/benchmarks
โ”‚       โ”œโ”€โ”€ Dockerfile
โ”‚       โ””โ”€โ”€ requirements.txt
โ”‚
โ”œโ”€โ”€ adapters/                          # OpenWhisk (Local Dev)
โ”‚   โ””โ”€โ”€ openwhisk/
โ”‚       โ”œโ”€โ”€ billing/
โ”‚       โ”‚   โ””โ”€โ”€ __main__.py            # def main(args) โ†’ dict
โ”‚       โ””โ”€โ”€ benchmarks/
โ”‚           โ””โ”€โ”€ __main__.py
โ”‚
โ”œโ”€โ”€ frontend/
โ”‚   โ””โ”€โ”€ portal/                        # SPA (Alpine.js)
โ”‚       โ”œโ”€โ”€ index.html
โ”‚       โ”œโ”€โ”€ time/index.html
โ”‚       โ”œโ”€โ”€ billing/index.html
โ”‚       โ”œโ”€โ”€ benchmarks/index.html
โ”‚       โ””โ”€โ”€ assets/
โ”‚           โ”œโ”€โ”€ js/
โ”‚           โ”‚   โ”œโ”€โ”€ app.js             # Alpine.js Store
โ”‚           โ”‚   โ”œโ”€โ”€ api.js             # Fetch wrapper
โ”‚           โ”‚   โ””โ”€โ”€ auth.js            # OAuth flow
โ”‚           โ””โ”€โ”€ css/
โ”‚               โ””โ”€โ”€ portal.css
โ”‚
โ””โ”€โ”€ tests/
    โ”œโ”€โ”€ shared/                        # โญ Test Core once
    โ”‚   โ”œโ”€โ”€ test_pdf_generator.py
    โ”‚   โ””โ”€โ”€ test_aggregator.py
    โ”œโ”€โ”€ services/
    โ”‚   โ””โ”€โ”€ test_api.py
    โ””โ”€โ”€ e2e/
        โ””โ”€โ”€ test_full_flow.py

Core Implementation Examples

Billing: PDF Generator

# shared/billing/pdf_generator.py

from jinja2 import Environment, PackageLoader
from weasyprint import HTML
from .models import Invoice

class InvoicePDFGenerator:
    """
    Platform-agnostic PDF generation.
    No HTTP, no Firestore - pure business logic.
    """

    def __init__(self):
        self.env = Environment(
            loader=PackageLoader('shared.billing', 'templates')
        )
        self.template = self.env.get_template('invoice.html')

    def generate(self, invoice: Invoice) -> bytes:
        """
        Generate PDF bytes from Invoice model.

        Args:
            invoice: Validated Invoice model

        Returns:
            PDF as bytes
        """
        html_content = self.template.render(
            invoice=invoice,
            line_items=invoice.line_items,
            total=invoice.calculate_total(),
            vat=invoice.calculate_vat()
        )

        pdf_bytes = HTML(string=html_content).write_pdf()
        return pdf_bytes

Adapter: Cloud Run Worker

# services/worker/src/billing/generate_pdf.py

from fastapi import APIRouter, BackgroundTasks
from shared.billing.pdf_generator import InvoicePDFGenerator
from shared.billing.models import Invoice

router = APIRouter()

@router.post("/worker/billing/generate-pdf")
async def generate_pdf(invoice_id: str, background_tasks: BackgroundTasks):
    """
    Cloud Run adapter for PDF generation.
    Handles HTTP, Firestore, GCS - delegates logic to shared core.
    """
    background_tasks.add_task(generate_pdf_task, invoice_id)
    return {"status": "accepted", "invoice_id": invoice_id}


async def generate_pdf_task(invoice_id: str):
    db = get_firestore_client()
    storage = get_gcs_client()

    # Update status
    await db.update_invoice(invoice_id, {"status": "processing"})

    try:
        # Load data
        invoice_data = await db.get_invoice(invoice_id)
        invoice = Invoice(**invoice_data)

        # Core logic (platform-agnostic)
        generator = InvoicePDFGenerator()
        pdf_bytes = generator.generate(invoice)

        # Upload to GCS
        pdf_url = await storage.upload(
            f"invoices/{invoice_id}.pdf",
            pdf_bytes,
            content_type="application/pdf"
        )

        # Update status
        await db.update_invoice(invoice_id, {
            "status": "ready",
            "pdf_url": pdf_url
        })

    except Exception as e:
        await db.update_invoice(invoice_id, {
            "status": "failed",
            "error": str(e)
        })
        raise

Adapter: OpenWhisk Action

# adapters/openwhisk/billing/__main__.py

from shared.billing.pdf_generator import InvoicePDFGenerator
from shared.billing.models import Invoice

def main(args: dict) -> dict:
    """
    OpenWhisk adapter for PDF generation.
    Same core logic, different platform wrapper.
    """
    try:
        invoice_data = args.get("invoice")
        invoice = Invoice(**invoice_data)

        generator = InvoicePDFGenerator()
        pdf_bytes = generator.generate(invoice)

        # Base64 encode for JSON response
        import base64
        pdf_base64 = base64.b64encode(pdf_bytes).decode()

        return {
            "statusCode": 200,
            "body": {
                "pdf_base64": pdf_base64,
                "filename": f"invoice-{invoice.id}.pdf"
            }
        }

    except Exception as e:
        return {
            "statusCode": 500,
            "body": {"error": str(e)}
        }

API Design

Endpoints

Base: https://clarissa-portal-api-xxxxx.run.app/api/v1

Auth
โ”œโ”€โ”€ GET  /auth/login              โ†’ GitLab OIDC redirect
โ”œโ”€โ”€ GET  /auth/callback           โ†’ OAuth callback
โ”œโ”€โ”€ POST /auth/refresh            โ†’ Refresh session
โ”œโ”€โ”€ POST /auth/logout             โ†’ Clear session
โ””โ”€โ”€ GET  /auth/me                 โ†’ Current user

Time Entries
โ”œโ”€โ”€ GET    /time/entries          โ†’ List (paginated)
โ”œโ”€โ”€ POST   /time/entries          โ†’ Create
โ”œโ”€โ”€ GET    /time/entries/{id}     โ†’ Get one
โ”œโ”€โ”€ PUT    /time/entries/{id}     โ†’ Update
โ”œโ”€โ”€ DELETE /time/entries/{id}     โ†’ Delete
โ””โ”€โ”€ GET    /time/summary          โ†’ Aggregated stats

Billing
โ”œโ”€โ”€ GET  /billing/invoices              โ†’ List invoices
โ”œโ”€โ”€ POST /billing/invoices              โ†’ Create draft
โ”œโ”€โ”€ GET  /billing/invoices/{id}         โ†’ Get (incl. status)
โ”œโ”€โ”€ PUT  /billing/invoices/{id}         โ†’ Update draft
โ”œโ”€โ”€ POST /billing/invoices/{id}/generate-pdf  โ†’ Trigger async
โ”‚        โ””โ”€โ”€ Returns: 202 Accepted { status: "queued" }
โ””โ”€โ”€ GET  /billing/invoices/{id}/pdf     โ†’ Redirect to GCS

Benchmarks
โ”œโ”€โ”€ GET  /benchmarks/latest       โ†’ Latest per runner
โ”œโ”€โ”€ GET  /benchmarks/history      โ†’ Time range query
โ””โ”€โ”€ POST /benchmarks/ingest       โ†’ CI posts results

Request/Response Contract

# shared/billing/models.py

from pydantic import BaseModel, Field
from datetime import date
from decimal import Decimal
from enum import Enum

class InvoiceStatus(str, Enum):
    DRAFT = "draft"
    QUEUED = "queued"
    PROCESSING = "processing"
    READY = "ready"
    FAILED = "failed"

class LineItem(BaseModel):
    description: str
    hours: Decimal = Field(ge=0)
    rate: Decimal = Field(ge=0)

    @property
    def amount(self) -> Decimal:
        return self.hours * self.rate

class Invoice(BaseModel):
    id: str
    client_id: str
    contractor_id: str
    period_start: date
    period_end: date
    line_items: list[LineItem]
    status: InvoiceStatus = InvoiceStatus.DRAFT
    pdf_url: str | None = None
    error: str | None = None

    def calculate_total(self) -> Decimal:
        return sum(item.amount for item in self.line_items)

    def calculate_vat(self, rate: Decimal = Decimal("0.20")) -> Decimal:
        return self.calculate_total() * rate

Error Handling

Status State Machine

         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
         โ”‚  draft  โ”‚
         โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜
              โ”‚ POST .../generate-pdf
              โ–ผ
         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
         โ”‚ queued  โ”‚
         โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”˜
              โ”‚ Worker picks up
              โ–ผ
       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
       โ”‚  processing  โ”‚
       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
              โ”‚
     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
     โ”‚                 โ”‚
     โ–ผ                 โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  ready  โ”‚      โ”‚  failed  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜      โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
                      โ”‚ retry_count < 3
                      โ–ผ
                 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                 โ”‚ queued  โ”‚ (retry)
                 โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Retry Strategy

# shared/core/retry.py

from tenacity import retry, stop_after_attempt, wait_exponential

RETRY_CONFIG = {
    "stop": stop_after_attempt(3),
    "wait": wait_exponential(multiplier=1, min=4, max=60),
    "reraise": True
}

@retry(**RETRY_CONFIG)
async def execute_with_retry(func, *args, **kwargs):
    return await func(*args, **kwargs)

API Error Responses

# Standard error response format

{
    "error": {
        "code": "INVOICE_NOT_FOUND",
        "message": "Invoice INV-001 not found",
        "correlation_id": "550e8400-e29b-41d4-a716-446655440000"
    }
}

# HTTP Status Codes
# 400 - Validation error (bad input)
# 401 - Not authenticated
# 403 - Not authorized
# 404 - Resource not found
# 409 - Conflict (e.g., invoice already processing)
# 500 - Internal error (with correlation_id for debugging)

Data Model (Firestore)

firestore/
โ”œโ”€โ”€ users/
โ”‚   โ””โ”€โ”€ {user_id}/
โ”‚       โ”œโ”€โ”€ gitlab_id: string
โ”‚       โ”œโ”€โ”€ email: string
โ”‚       โ”œโ”€โ”€ name: string
โ”‚       โ”œโ”€โ”€ avatar_url: string
โ”‚       โ”œโ”€โ”€ role: "admin" | "user"
โ”‚       โ”œโ”€โ”€ refresh_token: string (encrypted)
โ”‚       โ””โ”€โ”€ created_at: timestamp
โ”‚
โ”œโ”€โ”€ time_entries/
โ”‚   โ””โ”€โ”€ {entry_id}/
โ”‚       โ”œโ”€โ”€ user_id: string
โ”‚       โ”œโ”€โ”€ project: string
โ”‚       โ”œโ”€โ”€ task: string
โ”‚       โ”œโ”€โ”€ duration_hours: number
โ”‚       โ”œโ”€โ”€ date: date
โ”‚       โ”œโ”€โ”€ notes: string
โ”‚       โ””โ”€โ”€ created_at: timestamp
โ”‚
โ”œโ”€โ”€ invoices/
โ”‚   โ””โ”€โ”€ {invoice_id}/
โ”‚       โ”œโ”€โ”€ client_id: string
โ”‚       โ”œโ”€โ”€ contractor_id: string
โ”‚       โ”œโ”€โ”€ period_start: date
โ”‚       โ”œโ”€โ”€ period_end: date
โ”‚       โ”œโ”€โ”€ line_items: array
โ”‚       โ”œโ”€โ”€ status: string
โ”‚       โ”œโ”€โ”€ pdf_url: string | null
โ”‚       โ”œโ”€โ”€ error: string | null
โ”‚       โ”œโ”€โ”€ retry_count: number
โ”‚       โ””โ”€โ”€ timestamps...
โ”‚
โ””โ”€โ”€ benchmarks/
    โ””โ”€โ”€ {benchmark_id}/
        โ”œโ”€โ”€ runner_id: string
        โ”œโ”€โ”€ executor: "shell" | "docker" | "k8s"
        โ”œโ”€โ”€ machine: string
        โ”œโ”€โ”€ pipeline_id: string
        โ”œโ”€โ”€ job_id: string
        โ”œโ”€โ”€ duration_seconds: number
        โ”œโ”€โ”€ status: "success" | "failed"
        โ””โ”€โ”€ timestamp: timestamp

Firestore Security Rules

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Users can only read/write their own data
    match /users/{userId} {
      allow read, write: if request.auth.uid == userId;
    }

    match /time_entries/{entryId} {
      allow read, write: if request.auth.uid == resource.data.user_id;
      allow create: if request.auth.uid == request.resource.data.user_id;
    }

    // Invoices: contractors can see their own
    match /invoices/{invoiceId} {
      allow read: if request.auth.uid == resource.data.contractor_id;
      allow write: if request.auth.uid == resource.data.contractor_id 
                   && resource.data.status == "draft";
    }

    // Benchmarks: anyone authenticated can read, only CI can write
    match /benchmarks/{benchmarkId} {
      allow read: if request.auth != null;
      allow write: if false; // Only via Admin SDK
    }
  }
}

Frontend Architecture

Tech Stack

  • Alpine.js: Reactive UI (15KB, no build step)
  • HTMX: HTML-over-the-wire (optional, for simple interactions)
  • Tailwind CSS: Utility-first styling
  • Chart.js: Benchmark visualizations

Structure

<!-- frontend/portal/index.html -->

<!DOCTYPE html>
<html lang="en">
<head>
    <title>CLARISSA Portal</title>
    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="/assets/js/app.js"></script>
</head>
<body x-data="app" class="bg-gray-100">

    <!-- Navigation -->
    <nav class="bg-white shadow" x-show="$store.auth.isAuthenticated">
        <a href="/portal/">Dashboard</a>
        <a href="/portal/time/">Time</a>
        <a href="/portal/billing/">Billing</a>
        <a href="/portal/benchmarks/">Benchmarks</a>
        <button @click="$store.auth.logout()">Logout</button>
    </nav>

    <!-- Login -->
    <div x-show="!$store.auth.isAuthenticated" class="flex items-center justify-center h-screen">
        <button @click="$store.auth.login()" class="btn-primary">
            Login with GitLab
        </button>
    </div>

    <!-- Content -->
    <main x-show="$store.auth.isAuthenticated">
        <!-- Page content loaded here -->
    </main>

</body>
</html>

Alpine.js Store

// frontend/portal/assets/js/app.js

const API_BASE = 'https://clarissa-portal-api-xxxxx.run.app/api/v1';

document.addEventListener('alpine:init', () => {

    // Auth Store
    Alpine.store('auth', {
        user: null,
        isAuthenticated: false,

        async init() {
            await this.checkSession();
        },

        async checkSession() {
            try {
                const res = await fetch(`${API_BASE}/auth/me`, { 
                    credentials: 'include' 
                });
                if (res.ok) {
                    this.user = await res.json();
                    this.isAuthenticated = true;
                }
            } catch (e) {
                this.isAuthenticated = false;
            }
        },

        login() {
            window.location.href = `${API_BASE}/auth/login`;
        },

        async logout() {
            await fetch(`${API_BASE}/auth/logout`, { 
                method: 'POST', 
                credentials: 'include' 
            });
            this.isAuthenticated = false;
            window.location.href = '/';
        }
    });

    // API Helper
    Alpine.magic('api', () => ({
        async get(endpoint) {
            const res = await fetch(`${API_BASE}${endpoint}`, { 
                credentials: 'include' 
            });
            if (!res.ok) throw new Error(`API Error: ${res.status}`);
            return res.json();
        },

        async post(endpoint, data) {
            const res = await fetch(`${API_BASE}${endpoint}`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify(data)
            });
            if (!res.ok) throw new Error(`API Error: ${res.status}`);
            return res.json();
        }
    }));
});

Implementation Roadmap

Phase 1: Core (Week 1-2)

  • [ ] shared/ module structure
  • [ ] Pydantic models
  • [ ] PDF generator (Jinja2 + WeasyPrint)
  • [ ] Unit tests for Core

Phase 2: Services (Week 3-4)

  • [ ] Portal API endpoints
  • [ ] Worker Service
  • [ ] Firestore integration
  • [ ] Error handling + retry

Phase 3: Frontend (Week 5)

  • [ ] Alpine.js setup
  • [ ] Auth flow (Login/Logout)
  • [ ] Time tracking UI
  • [ ] Billing UI

Phase 4: Polish (Week 6)

  • [ ] Benchmark dashboard
  • [ ] E2E tests
  • [ ] Documentation

References