Document Version: 1.0.11 Status: In Progress Last Updated: 09 April 2026
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]
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.
| 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 |
| 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.imagedefaults 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.
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
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.tsxandroute.ts— are 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 inapp/**/route.ts.
apps/cron is a Cloudflare Workers application that runs scheduled tasks:
- Periodically rotates
joinIdfor all active organizations.
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"
}
}packages/core is the core of Domus — completely framework-agnostic and independent of Next.js, Drizzle, or any other library.
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 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.
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')
}
}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 availableServer 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.
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).
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)
Services are where all of Domus's business logic lives. Services must not depend on any framework (Next.js, HTTP, etc.) — pure TypeScript only.
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
}
}AuthContextis 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. — noAuthContextneeded
- ✅ Write operations:
AuthContextis retrieved from the session in the Server Action before calling the service
| 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.
// 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 (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.
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.
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>
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.
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 |
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
app/api/auth/[...all]/route.ts → thin export to src/app/api-routes/auth.ts
The Next.js proxy runs at the project root and handles:
- Session validation (redirect to
/loginif no session) accountStatusvalidation — only users withapprovedstatus can access the application- Routing based on status:
pending→ redirect to Awaiting Approval pagerejected→ redirect to Access Denied page
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.
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. |
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. |
- Roles are not inherited automatically — all assignments are explicit.
- A
parish-adminwho wants to manage a specific organization must be assigned as anadminfor that organization. - Admins of a parent organization do not automatically become admins of sub-organizations.
- Financial reports are accessible only by the
treasurer,pastor, andexecutive-board.
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
| Environment | Branch | Database |
|---|---|---|
| Development | local | Local PostgreSQL |
| Preview | PR / feature branch | Neon DB Branching (isolated per PR) |
| Production | main |
Neon PostgreSQL (prod) |
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]
Husky runs local checks before commit:
- pre-commit: Biome lint & format
- commit-msg: Commitlint (Conventional Commits)
- GitHub: github.com/paroki/domus
- Production Domain: dash.pkrbt.id
| 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 |
| 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 |
| 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.
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.
| 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. |
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 })
// ...
}
}| 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 |
- Structured JSON only — no
console.login production code. - Level convention:
info— incoming request, successful operationwarn— abnormal but non-fatal condition (e.g., GPS failed,joinIdexpired)error— caught exception, failed operation
- No PII in logs — sensitive fields such as
idCardPhoto,email, andfullNamemust not be included in logs. - In
development, usepino-prettyfor human-readable output. - In
production, output is raw JSON sent to Axiom.
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
}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 |
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.
Domus uses two separate storage providers, each with a dedicated contract interface in packages/core/src/contract/.
| 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 |
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>
}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>
}| 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 |
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 |
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.
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.
| 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 |
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.