Skip to content

Latest commit

 

History

History
1009 lines (772 loc) · 36.9 KB

File metadata and controls

1009 lines (772 loc) · 36.9 KB

Technical Design Document (TDD)

Domus — Digital Administration for Kristus Raja Barong Tongkok Catholic Parish

MVP Version


Document Version: 1.0.11 Status: In Progress Last Updated: 09 April 2026


1. System Overview

Domus is built with a monolith architecture for the MVP. This approach was chosen due to the application's limited scale (a single parish), a small development team, and the need to move fast without the complexity of distributed services.

The Domus client-server model follows this pattern:

graph TD
    PWA[PWA - Browser] --> SA[Server Actions / Route Handlers]
    SA --> DB[(Database)]
    SA --> Auth[Better Auth - Google OAuth]
    SA --> R2[Cloudflare R2]
    SA --> GD[Google Drive]
Loading

All business logic is managed within a single Next.js application with clear separation of concerns through a Clean Architecture at the packages/core layer.


2. Tech Stack

Category Choice Description
Framework Next.js 16 (App Router) SSR, React Server Components, Server Actions
PWA Serwist PWA plugin for Next.js
Monorepo Turborepo + pnpm workspaces Build & task orchestration
Auth Better Auth Google OAuth, organization & admin plugins, passwordless
ORM Drizzle ORM Type-safe, migration-first
Database (Prod) PostgreSQL — Neon Serverless PostgreSQL with DB branching
Database (Dev) Local PostgreSQL
Media Storage Cloudflare R2 Public assets rendered directly via URL
Document Storage Google Drive Documents viewed via Google Drive Viewer
Hosting Vercel
Cron Jobs Cloudflare Workers joinId rotation and other scheduled tasks
CI/CD GitHub Actions → Vercel Quality gate before deployment
Logger @axiomhq/logging Axiom-native logging for Next.js and Cloudflare Workers
Log Storage Axiom Log sink & storage. Free tier: 500 GB ingest/month, 30-day retention
Error Tracking Sentry (optional, future) Exception tracking via Vercel integration. Not included in MVP.
Monitoring Vercel Analytics / custom queries Track KPIs for adoption and <2s performance
Linter & Formatter Biome Replaces ESLint + Prettier
Git Hooks Husky Runs checks before commit & push
Commit Convention Conventional Commits + Commitlint Standard commit message format
UUID Generation uuid (pnpm package) UUID v7 generation for all non-Better Auth tables

Storage Division

File Type Storage Access Method Entity Field
Org logos, org covers Cloudflare R2 Direct URL (<img src>) organizations.logo, organizations.cover
User profile photos (custom) Cloudflare R2 Direct URL (<img src>) users.image
ID card photos Google Drive Google Drive Viewer URL parishioners.idCardPhoto
Transaction receipts Google Drive Google Drive Viewer URL transactions.receiptPhoto
Event attachments (photos, docs) Google Drive Google Drive Viewer URL attachments table (referenceType: 'Event')
Org documents (AD/ART, notulen) Google Drive Google Drive Viewer URL attachments table (referenceType: 'Organization')
SK documents & term files Google Drive Google Drive Viewer URL attachments table (referenceType: 'Term')

Note: users.image defaults to the Google OAuth profile photo URL. If the user uploads a custom photo, it is stored in Cloudflare R2 and the field is updated to the R2 public URL.

Note: Transaction receipt photos (transactions.receiptPhoto) remain as a dedicated field — not part of the general attachment system — to keep financial queries simple and audit trails clean.


3. Monorepo Architecture

The monorepo is managed using Turborepo with a Just-in-Time (JIT) Packages strategy for all internal packages. With this strategy, each package exports TypeScript directly without its own build step — compiled by the application bundler (Turbopack) at build time.

apps/
├── dash/                         # Next.js 16 — main Domus application
└── cron/                         # Cloudflare Workers — scheduled tasks

packages/
├── core/                         # Framework-agnostic, Clean Architecture
│   ├── contract/
│   │   ├── parishioner.ts        # IParishionerRepository (flat, no subdir)
│   │   ├── attachment.ts         # IAttachmentRepository
│   │   ├── storage-public.ts     # IPublicStorage
│   │   ├── storage-private.ts    # IPrivateStorage
│   │   └── logger.ts             # ILogger interface
│   ├── entity/                   # Zod schemas as value objects + inferred types
│   │   ├── auth-context.ts       # AuthContext interface
│   │   ├── attachment.ts         # AttachmentEntity
│   │   └── enums.ts
│   ├── error/                    # CoreError + subclasses
│   ├── service/                  # Business logic, class-based, returns Result<T>
│   │   └── attachment.ts         # AttachmentService
│   └── utils/                    # ok(), fail(), and helper functions
├── db/                           # Drizzle schema, migrations, db client, repository implementations
├── auth/                         # Better Auth config
└── storage/                      # Cloudflare R2 and Google Drive adapter implementations

3.1 apps/dash Structure

apps/dash follows Feature-Sliced Design (FSD) architecture adapted for the Next.js App Router. The app/ folder at the project root belongs to Next.js — all FSD source code lives inside src/.

apps/dash/
├── app/                          # Next.js App Router (thin exports only — NO logic)
│   ├── (auth)/
│   │   └── login/
│   │       └── page.tsx          # thin export, no wrapper, no logic
│   ├── (dash)/
│   │   └── org/
│   │       └── [id]/
│   │           └── page.tsx      # thin export, no wrapper, no logic
│   ├── api/
│   │   ├── auth/
│   │   │   └── [...all]/
│   │   │       └── route.ts      # thin export, no wrapper, no logic
│   │   └── qr/
│   │       └── [eventId]/
│   │           └── route.ts      # thin export, no wrapper, no logic
│   └── layout.tsx
├── pages/
│   └── README.md                 # Empty folder, required by Next.js
└── src/
    ├── app/                      # FSD app layer
    │   ├── providers/
    │   └── api-routes/           # Route Handler implementations (adapter layer)
    ├── pages/                    # FSD pages layer
    ├── widgets/
    ├── features/
    ├── entities/
    └── shared/

Hard rules:

  • All files in app/ — both .tsx and route.tsare thin exports only, no wrappers, no logic:
    // ✅ Correct
    export { LoginPage as default } from '@/pages/login'
    
    // ❌ Wrong
    export default function Page() { return <LoginPage /> }
  • All business logic lives in packages/core/service/, not in the application layer.
  • All route handler implementations live in src/app/api-routes/, not in app/**/route.ts.

3.2 apps/cron Structure

apps/cron is a Cloudflare Workers application that runs scheduled tasks:

  • Periodically rotates joinId for all active organizations.

3.3 Package Conventions

All internal packages use the @domus/* namespace:

@domus/core
@domus/db
@domus/auth
@domus/storage

Example package.json for a JIT package:

{
  "name": "@domus/core",
  "exports": {
    "./entity/*": "./src/entity/*.ts",
    "./service/*": "./src/service/*.ts",
    "./error": "./src/error/index.ts",
    "./utils": "./src/utils/index.ts",
    "./contract/*": "./src/contract/*.ts"
  }
}

4. Clean Architecture (packages/core)

packages/core is the core of Domus — completely framework-agnostic and independent of Next.js, Drizzle, or any other library.

4.1 entity/ Layer

Entities are defined as Zod schemas that serve simultaneously as value objects and type inference sources:

// packages/core/src/entity/parishioner.ts
import { z } from 'zod'
import { Gender, EducationLevel } from './enums'

export const ParishionerEntity = z.object({
  id: z.string(),
  userId: z.string().nullable(),
  fullName: z.string().min(1),
  honorific: z.string().nullable(),
  suffix: z.string().nullable(),
  gender: z.nativeEnum(Gender).nullable(),
  educationLevel: z.nativeEnum(EducationLevel).nullable(),
})

export type Parishioner = z.infer<typeof ParishionerEntity>

/**
 * Represents a pending parishioner membership request with enriched details.
 * Used for administrative verification.
 */
export interface ParishionerMembershipRequest {
  id: string;             // Membership record ID
  parishionerId: string;
  fullName: string;
  organizationName: string;
  avatarUrl: string | null;
  idCardNumber: string | null;
  idCardPhoto: string | null;
  birthPlace: string | null;
  birthDate: Date | null;
  createdAt: Date;
}

AuthContext

AuthContext is a plain object representing the identity and access rights of the currently active user. Defined in packages/core so it can be used by services without depending on a framework.

// packages/core/src/entity/auth-context.ts
export interface AuthContext {
  userId: string
  role: string
  orgRoles?: Record<string, string> // { [orgId]: scopedRole }
}

AuthContext is not a Zod entity — no runtime validation needed. A TypeScript interface is sufficient because its value always comes from a session already validated at the apps/dash layer.

4.2 error/ Layer

All errors are derived from CoreError:

// packages/core/src/error/core-error.ts
export class CoreError extends Error {
  constructor(
    public code: string,
    public status: number,
    message: string
  ) {
    super(message)
    this.name = this.constructor.name
  }
}

export class NotFoundError extends CoreError {
  constructor(resource?: string) {
    super('NOT_FOUND', 404, resource ? `${resource} not found` : 'Not found')
  }
}

export class UnauthorizedError extends CoreError {
  constructor() {
    super('UNAUTHORIZED', 401, 'Unauthorized')
  }
}

export class ForbiddenError extends CoreError {
  constructor() {
    super('FORBIDDEN', 403, 'Access denied')
  }
}

4.3 utils/ Layer — Result Pattern

Services do not throw exceptions. All services return Result<T> following the Go pattern:

// packages/core/src/utils/result.ts
import { CoreError } from '../error/core-error'

export type Result<T> = [T, null] | [null, CoreError]

export function ok<T>(data: T): Result<T> {
  return [data, null]
}

export function fail<T>(error: CoreError): Result<T> {
  return [null, error]
}

Example usage in a service:

// packages/core/src/service/parishioner.ts
async findById(id: string): Promise<Result<Parishioner>> {
  const profile = await this.repo.findById(id)
  if (!profile) return fail(new NotFoundError('Parishioner'))
  return ok(profile)
}

Example usage in a Server Action:

// apps/dash/src/features/profile/actions/get-parishioner.ts
const [profile, error] = await parishionerService.findById(id)
if (error) {
  // handle error
}
// profile is safely available

4.4 Error Handling in Server Actions

Server Actions follow this convention for handling errors from services, returning a Go-style Result tuple:

// apps/dash/src/features/profile/actions/update-parishioner.ts
'use server'

import { ok, fail, type Result } from '@domus/core'
import { ForbiddenError, NotFoundError } from '@domus/core/error'
import { getAuthSession } from '@/shared/auth/session'

export async function updateParishionerAction(id: string, data: UpdateParishionerDto): Promise<Result<Parishioner>> {
  const [session, authError] = await getAuthSession()
  if (authError) return fail(authError)

  const ctx: AuthContext = session.context

  const [profile, error] = await parishionerService.update(id, data, ctx)

  if (error) {
    // Optionally transform core error to action error if needed
    return fail(error)
  }

  return ok(profile)
}

Server Action return type convention:

Server Actions must return the same Result<T> type used in the core layer:

type Result<T> = [T, null] | [null, CoreError]

Errors are never thrown from a Server Action — always returned as the second element of the tuple. The UI layer is responsible for checking if the second element exists (if (error)) and displaying error messages (toast, form error, etc.) using error.message.

4.5 contract/ Layer

Repository interfaces define the contract between services and database implementations. All contract files live directly in packages/core/src/contract/ without subdirectories:

// packages/core/src/contract/parishioner.ts
import { Parishioner } from '../entity/parishioner'

export interface IParishionerRepository {
  findById(id: string): Promise<Parishioner | null>
  findByUserId(userId: string): Promise<Parishioner | null>
  create(data: Omit<Parishioner, 'id'>): Promise<Parishioner>
  update(id: string, data: Partial<Parishioner>): Promise<Parishioner>
  delete(id: string): Promise<void>
}

/**
 * Attachment repository contract.
 */
export interface IAttachmentRepository {
  findById(id: string): Promise<Attachment | null>
  findByReference(referenceId: string, referenceType: string): Promise<Attachment[]>
  create(data: Omit<Attachment, 'id' | 'createdAt' | 'deletedAt'>): Promise<Attachment>
  delete(id: string): Promise<void>
}

Storage contracts also live in packages/core/src/contract/ — see Section 13 — Storage Architecture.

Concrete implementations live in packages/db/ (repositories) and packages/storage/ (storage adapters).

4.6 Enum Conventions

All enums use the const object + as const pattern for full compatibility with Drizzle ORM and tree-shaking:

// packages/core/src/entity/enums.ts
export const AccountStatus = {
  Pending: 'pending',
  Approved: 'approved',
  Rejected: 'rejected',
} as const
export type AccountStatus = typeof AccountStatus[keyof typeof AccountStatus]

export const Gender = {
  Male: 'male',
  Female: 'female',
} as const
export type Gender = typeof Gender[keyof typeof Gender]

String value conventions:

  • Single-word values → plain lowercase (e.g., 'pending', 'primary', 'region')
  • Multi-word values → kebab-case (e.g., 'junior-high', 'not-attending')
  • Acronyms/abbreviations → uppercase keys, lowercase values (e.g., BEC: 'bec')

Key conventions:

  • Single-word → PascalCase (e.g., Pending, Male)
  • Multi-word → PascalCase concatenated (e.g., PrimarySchool, NotAttending)
  • Acronyms → uppercase (e.g., BEC, QrCode)

4.7 service/ Layer

Services are where all of Domus's business logic lives. Services must not depend on any framework (Next.js, HTTP, etc.) — pure TypeScript only.

Class Structure

Each service is implemented as a class with constructor injection for repositories:

// packages/core/src/service/parishioner.ts
export class ParishionerService {
  constructor(private readonly repo: IParishionerRepository) {}

  async findById(id: string): Promise<Result<Parishioner>> {
    const data = await this.repo.findById(id)
    if (!data) return fail(new NotFoundError('Parishioner'))
    return ok(data)
  }

  async create(
    data: CreateParishionerDto,
    ctx: AuthContext,
  ): Promise<Result<Parishioner>> {
    // permission check using ctx
    // business logic
  }
}

AuthContext Rules

  • AuthContext is passed as an explicit parameter — never optional (ctx?: AuthContext)
  • Only methods that require a permission check receive AuthContext:
    • ✅ Write operations: create, update, delete
    • ✅ Domain-specific operations: approve, reject, lock, unlock, etc.
    • ❌ Read operations: findById, findAll, etc. — no AuthContext needed
  • AuthContext is retrieved from the session in the Server Action before calling the service

Auth Responsibility Division

Layer Responsibility
Next.js Proxy Session validation, accountStatus check, routing by status
Server Action Retrieve session, construct AuthContext, pass to service
Service Permission check using AuthContext for write/domain-specific ops
Service Pure business logic for read operations (no auth)

Services do not perform authentication checks — only authorization (permission checks) on operations that require it. Authentication is entirely the responsibility of the NextJS Proxy and Server Actions in apps/dash.

Example Usage in a Server Action

// apps/dash/src/features/enrollment/actions/approve-enrollment.ts
'use server'

import type { AuthContext } from '@domus/core/entity/auth-context'

export async function approveEnrollmentAction(enrollmentId: string) {
  const session = await auth.getSession()
  if (!session) return { success: false, code: 'UNAUTHORIZED', message: 'Not authenticated' }

  const ctx: AuthContext = {
    userId: session.user.id,
    role: session.user.role,
    orgRoles: session.user.orgRoles,
  }

  const [result, error] = await enrollmentService.approve(enrollmentId, ctx)

  if (error) {
    if (error instanceof ForbiddenError) {
      return { success: false, code: 'FORBIDDEN', message: error.message }
    }
    return { success: false, code: 'INTERNAL_ERROR', message: 'An error occurred' }
  }

  return { success: true, data: result }
}

Request Context

Request context (headers, IP address, user-agent, etc.) does not enter services. This is an exclusive concern of the apps/dash layer — handled by NextJS Proxy or Server Actions as needed.

4.8 Financial Period Locking Enforcement

All write operations (create, update, delete) on the transactions table must verify the status of the related financial period:

  • Rule: Transactions can only be modified if financial_periods.status = 'open'.
  • Enforcement: The check is performed at the Business Service layer (@domus/core) before calling the repository.

5. Business Flows

5.1 PII Purge Logic (Google Drive & Database)

Sensitive data (ID card photos) is temporary for verification. Rejection triggers a full purge for unapproved users.

sequenceDiagram
    participant Admin
    participant Service as EnrollmentService
    participant GD as Google Drive
    participant DB as Database

    Admin->>Service: reject(enrollmentId)
    Service->>DB: Get enrollment & parishioner
    Service->>GD: IPrivateStorage.delete(fileId)
    
    alt User is Approved
        Service->>DB: Update user.accountStatus to 'rejected'
        Service->>DB: Soft delete enrollment & parishioner
    else User is Unapproved (Pending/Rejected)
        Service->>DB: Hard delete user record
        Note right of DB: Cascade purges parishioners & org_enrollments
    end
    
    Service->>Admin: Return Result<OK>
Loading

5.2 Attendance & GPS Validation

Location validation uses the Haversine Formula to calculate the distance between the parishioner's coordinates and the event's center point (radiusMeters).

  • Self-Attendance (QR/GPS): Recorded immediately if within radius.
  • Manual Request: Submit current coordinates (as proof) if outside radius → enters the Admin's manual approval queue.

6. Naming Conventions

Consistent naming is critical for maintainability and code readability, especially in an open source project.

Context Convention Example
Database table names snake_case financial_periods, org_enrollments, attachments
Field / column names camelCase createdAt, organizationId, referenceType
Drizzle schema constant names camelCase orgUnits, orgEnrollments, financialPeriods, attachments
Entity names (Zod schema) PascalCase + Entity suffix ParishionerEntity, EventEntity, AttachmentEntity
Inferred types from entities PascalCase Parishioner, Event, Attachment
Enum names PascalCase AccountStatus, EnrollmentStatus
Enum keys PascalCase AccountStatus.Pending
Enum string values (single-word) lowercase 'pending', 'primary'
Enum string values (multi-word) kebab-case 'junior-high', 'not-attending', 'super-admin'
Repository interface names PascalCase + I prefix IParishionerRepository, IAttachmentRepository
Storage interface names PascalCase + I prefix IPublicStorage, IPrivateStorage
Service class names PascalCase + Service suffix ParishionerService, AttachmentService
Service instance variable names camelCase + Service suffix parishionerService, attachmentService
DTO names PascalCase + Dto suffix CreateParishionerDto, UpdateParishionerDto
Server Action names camelCase + Action suffix updateParishionerAction
File names kebab-case parishioner.ts, get-parishioner.ts, attachment.ts
Folder names kebab-case org-enrollments/, financial-periods/
Internal package names @domus/ + kebab-case @domus/core, @domus/db, @domus/storage
Variables & functions camelCase getParishioner, userId
Global constants SCREAMING_SNAKE_CASE MAX_UPLOAD_SIZE
React components PascalCase ParishionerCard, EventList
Route Group (Public) (pubs) app/(pubs)/join/[joinId]
Route Group (Auth) (auth) app/(auth)/login
Route Group (Dash) (dash) app/(dash)/org

7. Authentication

Authentication is managed by Better Auth with the following configuration:

  • Provider: Google OAuth (the only login method, passwordless)
  • organization plugin: Manages membership and scoped roles per organization
  • admin plugin: Manages global user roles

7.1 Route Handler

app/api/auth/[...all]/route.ts → thin export to src/app/api-routes/auth.ts

7.2 Proxy

The Next.js proxy runs at the project root and handles:

  1. Session validation (redirect to /login if no session)
  2. accountStatus validation — only users with approved status can access the application
  3. Routing based on status:
    • pending → redirect to Awaiting Approval page
    • rejected → redirect to Access Denied page

8. Roles & Access Control

Domus uses a hybrid role model — a combination of global roles and scoped roles per organization. A single user can have more than one role simultaneously. The default role for all new users is parishioner.

8.1 Global Roles

Managed by the Better Auth admin plugin.

UserRole Access
super-admin System management: user management, transaction categories, joinId rotation, etc. No access to other parish features.
parish-admin Creates and manages parish-level organizations. Creates public events. Must be manually assigned as an admin per organization to manage it.
treasurer Records transactions, manages categories, and locks/unlocks financial periods.
pastor Views financial reports.
executive-board Views financial reports.
parishioner RSVP and self-attendance at events. Default role for all users.

8.2 Scoped Roles (per Organization)

Managed by the Better Auth organization plugin.

UserRole Access
owner Full access within the organization.
admin Manages events, attendance, and organization members. Can verify/approve new enrollment registrations.
member Performs RSVP and attendance at organization events.

8.3 Permission Rules

  • Roles are not inherited automatically — all assignments are explicit.
  • A parish-admin who wants to manage a specific organization must be assigned as an admin for that organization.
  • Admins of a parent organization do not automatically become admins of sub-organizations.
  • Financial reports are accessible only by the treasurer, pastor, and executive-board.

8.4 Proxy Enforcement

flowchart TD
    A[Incoming request] --> B{Session exists?}
    B -- No --> C[Redirect /login]
    B -- Yes --> D{accountStatus = approved?}
    D -- pending --> E[Redirect /pending]
    D -- rejected --> F[Redirect /rejected]
    D -- approved --> G{Route type?}
    G -- Finance route --> H{UserRole: treasurer | pastor | executive-board?}
    H -- No --> I[403 Forbidden]
    H -- Yes --> J[Access granted]
    G -- Organization route --> K{Scoped role: owner | admin | member?}
    K -- No --> I
    K -- Yes --> J
    G -- System route --> L{UserRole: super-admin?}
    L -- No --> I
    L -- Yes --> J
Loading

9. Deployment & Infrastructure

9.1 Environment Strategy

Environment Branch Database
Development local Local PostgreSQL
Preview PR / feature branch Neon DB Branching (isolated per PR)
Production main Neon PostgreSQL (prod)

9.2 CI/CD Pipeline

flowchart LR
    A[Push / PR to GitHub] --> B[GitHub Actions]
    B --> C[Biome: lint & format check]
    C --> D[Type check]
    D --> E[Build check]
    E --> F[Vercel Deploy]
    F --> G{Branch?}
    G -- PR/feature --> H[Preview Deployment]
    G -- main --> I[Production Deployment\ndash.pkrbt.id]
Loading

Husky runs local checks before commit:

  • pre-commit: Biome lint & format
  • commit-msg: Commitlint (Conventional Commits)

9.3 Repository

9.4 Third-Party Services

Service Usage
Vercel Next.js application hosting
Neon Serverless PostgreSQL + DB branching
Cloudflare R2 Public media asset storage
Cloudflare Workers joinId rotation cron job and scheduled tasks
Google Drive Document & file storage with built-in viewer
Google OAuth User authentication
GitHub Actions CI/CD pipeline
Axiom Log storage and observability

10. Primary Key & Foreign Key Conventions

Primary Key Convention

Table Type PK Type Notes
Better Auth managed tables (users, organizations, members, sessions, accounts) text Managed by Better Auth internally
All custom tables uuid v7 Generated using v7() from the uuid pnpm package

Foreign Key Convention

FK target FK type in custom table Example
Better Auth table (users.id, organizations.id) text parishioners.userId: text?
Custom table uuid v7 attendances.parishionerId: uuid v7

This mixed type is by design — Better Auth manages its own ID generation as text, while all custom tables use uuid v7 for sortability and uniqueness guarantees. At the PostgreSQL level, uuid v7 values are valid text, so cross-references work correctly.


11. Logging

Domus uses Pino as the logger and Axiom as the log sink. Both operate independently from Sentry — there is no conflict if Sentry is added later.

11.1 Stack Overview

Component Choice Description
Logger @axiomhq/logging Unified logging interface with Axiom transport
Log Sink Axiom SaaS log storage. Free tier: 500 GB ingest/month, 30-day retention
Error Tracking (optional, future) Sentry Added via Vercel integration when needed. No conflict with Axiom tools.

11.2 ILogger Contract

ILogger is the interface that defines the logging contract across the entire Domus codebase. Defined in packages/core/src/contract/logger.ts so that all layers (service, repository, cron) share the same type without depending on a concrete implementation (Pino).

// packages/core/src/contract/logger.ts

/**
 * Structured log entry with optional contextual metadata.
 * Do NOT include PII fields such as email, fullName, or idCardPhoto.
 */
export interface LogContext {
  [key: string]: unknown
}

/**
 * Logger contract for Domus services.
 * Implementations must use structured JSON output — no plain string logs in production.
 *
 * Concrete implementations:
 * - `apps/dash`: `AxiomLogger` (Adapter for `@axiomhq/logging`)
 * - `packages/db`: `ILogger` via constructor injection (instance provided by consumer)
 * - `apps/cron`: `CronLogger` (Adapter for `@axiomhq/js` HTTP transport)
 */
export interface ILogger {
  /** Routine operational events — request received, operation succeeded. */
  info(message: string, context?: LogContext): void

  /** Abnormal but non-fatal conditions — GPS validation failed, joinId expired. */
  warn(message: string, context?: LogContext): void

  /** Caught exceptions or failed operations — use when a Result<T> returns a CoreError. */
  error(message: string, context?: LogContext): void

  /** Verbose diagnostic information — for development only, never in production. */
  debug(message: string, context?: LogContext): void

  /** Unrecoverable errors that require immediate attention — process should exit. */
  fatal(message: string, context?: LogContext): void
}

ILogger is exported via JIT package exports:

"./contract/logger": "./src/contract/logger.ts"

Usage in the repository layer (packages/db) — logger injected via constructor:

// packages/db/src/repository/enrollment.ts
import type { ILogger } from '@domus/core/contract/logger'

export class EnrollmentRepository implements IEnrollmentRepository {
  constructor(
    private readonly db: DrizzleClient,
    private readonly logger: ILogger,
  ) {}

  async findById(id: string) {
    this.logger.info('EnrollmentRepository.findById', { enrollmentId: id })
    // ...
  }
}

11.3 Coverage per Area

Area Implementation
Next.js / Server Actions AxiomLogger — Wrapper for @axiomhq/nextjs
Drizzle ORM queries ILogger injection in the repository layer (packages/db)
Cloudflare Workers (apps/cron) CronLogger — using @axiomhq/js HTTP transport

11.4 Logging Principles

  • Structured JSON only — no console.log in production code.
  • Level convention:
    • info — incoming request, successful operation
    • warn — abnormal but non-fatal condition (e.g., GPS failed, joinId expired)
    • error — caught exception, failed operation
  • No PII in logs — sensitive fields such as idCardPhoto, email, and fullName must not be included in logs.
  • In development, use pino-pretty for human-readable output.
  • In production, output is raw JSON sent to Axiom.

11.5 Cloudflare Workers — HTTP Transport

Cloudflare Workers do not have filesystem access, so Pino is configured using an HTTP transport to send logs directly to Axiom via REST API:

// apps/cron/src/logger.ts
import { Axiom } from '@axiomhq/js';
import { AxiomJSTransport, Logger } from '@axiomhq/logging';

const axiomClient = new Axiom({ token: process.env.AXIOM_TOKEN! });
const axiomLogger = new Logger({
  transports: [
    new AxiomJSTransport({
      axiom: axiomClient,
      dataset: process.env.AXIOM_DATASET!,
    }),
  ],
});

export class CronLogger implements ILogger {
  info(message: string, context?: LogContext) { axiomLogger.info(message, context); }
  // ... other ILogger methods mapped to axiomLogger
}

11.6 Environment Variables

Add the following variables to all environments (development, preview, production). Note that for apps/dash (Next.js), variables used on the client must be prefixed with NEXT_PUBLIC_.

Variable Description
AXIOM_TOKEN API token from the Axiom dashboard
AXIOM_DATASET Dataset name in Axiom (e.g., domus-prod, domus-preview)
NEXT_PUBLIC_AXIOM_TOKEN (Next.js client-side) Same as AXIOM_TOKEN
NEXT_PUBLIC_AXIOM_DATASET (Next.js client-side) Same as AXIOM_DATASET

11.7 Sentry — Optional Integration (Future)

Sentry can be added later via Vercel Marketplace without disrupting the Pino + Axiom stack:

  • Pino + Axiom → general observability (all log levels, request tracing)
  • Sentry → actionable error alerting (exception + stack trace + user context)

Both systems run in parallel and complement each other.


12. Storage Architecture

Domus uses two separate storage providers, each with a dedicated contract interface in packages/core/src/contract/.

12.1 Storage Division

Provider Type Used For
Cloudflare R2 Public Web-renderable assets served directly via URL
Google Drive Private Sensitive documents and attachments accessed via Drive Viewer

12.2 IPublicStorage Contract

Implemented by the Cloudflare R2 adapter in packages/storage.

// packages/core/src/contract/storage-public.ts

/**
 * Contract for public asset storage (Cloudflare R2).
 * Used for web-accessible assets: org logos, org covers, user profile photos.
 */
export interface IPublicStorage {
  /**
   * Uploads a file to public storage.
   * @param file - The file buffer to upload.
   * @param path - The destination path within the bucket (e.g., `orgs/{id}/logo.webp`).
   * @returns The public URL of the uploaded file.
   */
  upload(file: Buffer, path: string): Promise<string>

  /**
   * Deletes a file from public storage.
   * @param path - The path of the file to delete (same path used during upload).
   */
  delete(path: string): Promise<void>
}

12.3 IPrivateStorage Contract

Implemented by the Google Drive adapter in packages/storage.

// packages/core/src/contract/storage-private.ts

/**
 * Contract for private document storage (Google Drive via Service Account).
 * Used for sensitive or non-public files: ID card photos, receipt photos,
 * event attachments, org documents, SK documents.
 */
export interface IPrivateStorage {
  /**
   * Uploads a file to a specified Google Drive folder.
   * @param file - The file buffer to upload.
   * @param folderId - The Google Drive folder ID to upload into.
   * @param fileName - The name of the file in Google Drive.
   * @returns An object containing the Drive file ID and a viewer URL.
   */
  upload(
    file: Buffer,
    folderId: string,
    fileName: string,
  ): Promise<{ fileId: string; viewerUrl: string }>

  /**
   * Permanently deletes a file from Google Drive.
   * Used for PII purge (e.g., ID card photos after enrollment approval/rejection)
   * and for attachment deletion.
   * @param fileId - The Google Drive file ID to delete.
   */
  delete(fileId: string): Promise<void>
}

12.4 Path Conventions (Cloudflare R2)

Asset Type Path Pattern Example
Org logo orgs/{orgId}/logo.{ext} orgs/abc123/logo.webp
Org cover orgs/{orgId}/cover.{ext} orgs/abc123/cover.webp
User profile photo users/{userId}/avatar.{ext} users/xyz789/avatar.webp

12.5 Folder Conventions (Google Drive)

Each file type is stored in a dedicated Google Drive folder, configured via environment variables:

Asset Type Env Var
ID card photos GDRIVE_FOLDER_ID_CARD
Transaction receipts GDRIVE_FOLDER_RECEIPT
Event attachments GDRIVE_FOLDER_EVENT
Org documents GDRIVE_FOLDER_ORG_DOCS
SK documents & term files GDRIVE_FOLDER_SK_DOC

12.6 PII Purge

ID card photos (parishioners.idCardPhoto) are permanently deleted from Google Drive upon enrollment approval or rejection — whichever comes first. The purge is performed via IPrivateStorage.delete(fileId) at the service layer (EnrollmentService) before any status update is committed. See Section 5.1 — PII Purge Logic for the full flow.

12.7 Attachment Deletion

When an attachment record is soft-deleted, the corresponding Google Drive file is permanently deleted via IPrivateStorage.delete(fileId) at the service layer (AttachmentService). Soft delete alone is not sufficient — the physical file must also be removed to avoid orphaned files in Google Drive.

12.8 Environment Variables

Variable Description
R2_ACCOUNT_ID Cloudflare account ID
R2_ACCESS_KEY_ID R2 access key
R2_SECRET_ACCESS_KEY R2 secret key
R2_BUCKET_NAME R2 bucket name
R2_PUBLIC_URL Base public URL for R2 assets
GDRIVE_SERVICE_ACCOUNT_KEY Google Service Account JSON key (base64 encoded)
GDRIVE_FOLDER_ID_CARD Google Drive folder ID for ID card photos
GDRIVE_FOLDER_RECEIPT Google Drive folder ID for transaction receipts
GDRIVE_FOLDER_EVENT Google Drive folder ID for event attachments
GDRIVE_FOLDER_ORG_DOCS Google Drive folder ID for org documents
GDRIVE_FOLDER_SK_DOC Google Drive folder ID for SK documents & term files

13. License

Domus is released under the MIT license. All code and documentation are open source and can be freely used, modified, and distributed in accordance with the license terms.


This document is a living document and will be updated as the Domus architecture evolves.