Initial commit
This commit is contained in:
93
.cursor/rules/01-global-engineering.mdc
Normal file
93
.cursor/rules/01-global-engineering.mdc
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
description: Core engineering principles, TypeScript, validation, and definition of done
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
You are working in a TypeScript-first monorepo for a modern HOA management platform.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
- This repository uses `pnpm` workspaces.
|
||||||
|
- Top-level workspace structure:
|
||||||
|
- `apps/web` — Next.js frontend
|
||||||
|
- `apps/api` — Fastify backend
|
||||||
|
- `packages/*` — shared packages
|
||||||
|
- Prefer shared packages from the start for reusable concerns such as UI primitives, types, config, i18n, lint config, and tsconfig.
|
||||||
|
|
||||||
|
## Core engineering principles
|
||||||
|
- Prefer maintainability, clarity, and explicitness over cleverness.
|
||||||
|
- Prefer fewer dependencies unless a new dependency is clearly justified.
|
||||||
|
- When adding a dependency, always use the latest stable non-beta version.
|
||||||
|
- Do not add packages when platform features or existing dependencies are sufficient.
|
||||||
|
- Do not introduce anti-patterns.
|
||||||
|
- Always follow established patterns in the codebase.
|
||||||
|
- If a requested change conflicts with established architecture or patterns, stop and ask before proceeding.
|
||||||
|
|
||||||
|
## Planning and execution
|
||||||
|
- Before making large changes, propose a brief implementation plan first.
|
||||||
|
- “Large changes” include:
|
||||||
|
- modifications across multiple files
|
||||||
|
- schema changes
|
||||||
|
- new dependencies
|
||||||
|
- architecture changes
|
||||||
|
- changes spanning multiple workspaces
|
||||||
|
- Prefer small, reviewable changes over sweeping rewrites unless explicitly instructed otherwise.
|
||||||
|
|
||||||
|
## File and code organization
|
||||||
|
- Prefer modifying existing files over creating new abstractions unless a new abstraction is justified.
|
||||||
|
- Avoid speculative abstractions. Generalize after a second real use case.
|
||||||
|
- Do not create multi-thousand-line files.
|
||||||
|
- Split large files into focused modules before they become unwieldy.
|
||||||
|
- Keep modules small and cohesive.
|
||||||
|
- Prefer composition over inheritance.
|
||||||
|
- Prefer pure functions where practical.
|
||||||
|
- Do not create giant utility dumping-ground files.
|
||||||
|
|
||||||
|
## TypeScript standards
|
||||||
|
- Use strict TypeScript patterns.
|
||||||
|
- Avoid `any` unless absolutely unavoidable and explicitly justified.
|
||||||
|
- Prefer precise types, discriminated unions, and explicit return types where helpful for maintainability.
|
||||||
|
- Shared types and schemas should live in dedicated packages when reused across workspaces.
|
||||||
|
- **Always use extensionless imports.** Write `import { foo } from './foo'`, never `import { foo } from './foo.js'`. All TypeScript is processed by `tsx`, Next.js, or Vitest — none require explicit extensions. An ESLint rule enforces this.
|
||||||
|
|
||||||
|
## Validation and safety
|
||||||
|
- Validate all external boundaries.
|
||||||
|
- This includes:
|
||||||
|
- API request validation
|
||||||
|
- API response validation where appropriate
|
||||||
|
- environment variable validation
|
||||||
|
- form validation
|
||||||
|
- file upload validation
|
||||||
|
- Use Zod as the default schema validation library unless explicitly directed otherwise.
|
||||||
|
- **Examples:** Validate request body and params at every API route boundary; validate environment variables at app startup (e.g. with a Zod schema) and fail fast if invalid.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- Add JSDoc for every non-trivial function or method, whether internal or exported.
|
||||||
|
- JSDoc should be useful, not decorative.
|
||||||
|
- Include parameter descriptions, return values, thrown errors where relevant, and examples when useful.
|
||||||
|
- Do not add comments that merely restate obvious code.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
- Before considering work complete, run the smallest relevant checks during development and the full validation suite before finalizing.
|
||||||
|
- Full validation includes:
|
||||||
|
- Prettier
|
||||||
|
- lint
|
||||||
|
- typecheck
|
||||||
|
- tests
|
||||||
|
- Never mark work complete if lint, typecheck, or tests fail.
|
||||||
|
- When finalizing, summarize what checks were run and their outcome.
|
||||||
|
|
||||||
|
## Documentation and repo hygiene
|
||||||
|
- Keep related documentation up to date when behavior changes.
|
||||||
|
- This includes, where relevant:
|
||||||
|
- README files
|
||||||
|
- environment examples
|
||||||
|
- package scripts
|
||||||
|
- API docs
|
||||||
|
- setup docs
|
||||||
|
- New apps or packages should include appropriate scripts, test setup, lint/typecheck setup, and a README.
|
||||||
|
|
||||||
|
## Security and secrets
|
||||||
|
- Never hard-code secrets.
|
||||||
|
- Never commit mock credentials or insecure defaults.
|
||||||
|
- Secrets must only be stored in environment variables or the database, whichever is appropriate for the use case.
|
||||||
|
- Prefer secure defaults.
|
||||||
29
.cursor/rules/02-monorepo-and-dependencies.mdc
Normal file
29
.cursor/rules/02-monorepo-and-dependencies.mdc
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
description: pnpm workspaces, dependency rules, and shared package direction
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Shared package and dependency standards for the monorepo.
|
||||||
|
|
||||||
|
## Package management
|
||||||
|
- Use `pnpm` workspaces.
|
||||||
|
- Prefer workspace packages for shared logic rather than duplication between apps.
|
||||||
|
- Reusable logic should be extracted into `packages/*` only when there is a clear second use case or a foundational shared concern.
|
||||||
|
|
||||||
|
## Dependency rules
|
||||||
|
- Prefer fewer dependencies.
|
||||||
|
- Do not introduce a package solely for convenience if existing platform APIs or current project dependencies can solve the problem cleanly.
|
||||||
|
- Any new dependency must be justified briefly in code comments, PR notes, or task summary when it is not obvious.
|
||||||
|
- Use the latest stable non-beta version when adding dependencies.
|
||||||
|
- Prefer mature, actively maintained packages with strong TypeScript support.
|
||||||
|
|
||||||
|
## Shared package direction
|
||||||
|
Prefer dedicated packages for:
|
||||||
|
- UI primitives
|
||||||
|
- shared types
|
||||||
|
- shared schemas
|
||||||
|
- configuration
|
||||||
|
- internationalization helpers
|
||||||
|
- eslint config
|
||||||
|
- tsconfig
|
||||||
|
- test utilities when broadly reused
|
||||||
83
.cursor/rules/03-backend-api.mdc
Normal file
83
.cursor/rules/03-backend-api.mdc
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
description: Fastify backend, API design, Prisma, auth, and error handling
|
||||||
|
globs: apps/api/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
You are working on the backend in `apps/api`.
|
||||||
|
|
||||||
|
## Backend framework
|
||||||
|
- Use Fastify as the default backend framework.
|
||||||
|
- Prefer Fastify plugins and encapsulation patterns over ad hoc global behavior.
|
||||||
|
|
||||||
|
## API design
|
||||||
|
- Keep business logic out of route handlers.
|
||||||
|
- Route handlers should be thin and focused on:
|
||||||
|
- validation
|
||||||
|
- auth/context
|
||||||
|
- calling application services
|
||||||
|
- shaping responses
|
||||||
|
- Prefer explicit service-layer or domain-layer boundaries for business logic.
|
||||||
|
|
||||||
|
## Validation and contracts
|
||||||
|
- Validate all incoming requests with Zod or approved schema wrappers.
|
||||||
|
- Validate file uploads and all user-controlled input.
|
||||||
|
- Avoid trusting frontend input.
|
||||||
|
- Use explicit schemas for request and response shapes where practical.
|
||||||
|
|
||||||
|
## API documentation
|
||||||
|
- Maintain Swagger/OpenAPI support.
|
||||||
|
- New or changed endpoints should update the API schema/docs as part of the work.
|
||||||
|
- Keep API docs accurate and usable.
|
||||||
|
|
||||||
|
## Data access
|
||||||
|
- Prefer Prisma as the default ORM unless explicitly directed otherwise.
|
||||||
|
- Use Postgres as the primary relational database.
|
||||||
|
- Store relational/domain data in Postgres.
|
||||||
|
- Store document/image metadata in Postgres and file binaries in appropriate storage.
|
||||||
|
- Avoid leaking raw ORM/database logic into unrelated layers.
|
||||||
|
|
||||||
|
## Auth and permissions
|
||||||
|
- Roles and permissions are first-class concerns.
|
||||||
|
- Design with support for:
|
||||||
|
- board members
|
||||||
|
- treasurers
|
||||||
|
- owners
|
||||||
|
- tenants
|
||||||
|
- future administrative roles
|
||||||
|
- Support passwordless authentication patterns:
|
||||||
|
- magic link
|
||||||
|
- OIDC
|
||||||
|
- passkeys
|
||||||
|
- Do not hard-code assumptions that one deployment always maps to one HOA.
|
||||||
|
- Self-hosted mode supports single tenancy.
|
||||||
|
- SaaS mode must remain compatible with future or current multi-tenant architecture decisions.
|
||||||
|
|
||||||
|
## Auditing
|
||||||
|
- All destructive or sensitive actions must be auditable.
|
||||||
|
- Deletions and other important changes should be recorded in an audit log.
|
||||||
|
- Prefer soft-delete or explicit audit-aware flows where appropriate.
|
||||||
|
- Do not implement silent destructive behavior.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
- Use explicit, typed, and user-safe error handling.
|
||||||
|
- Never use silent catch blocks.
|
||||||
|
- Do not swallow errors.
|
||||||
|
- Return structured, predictable error responses.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD: silent catch
|
||||||
|
try {
|
||||||
|
await doSomething();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// GOOD: log, type, rethrow or return structured error
|
||||||
|
try {
|
||||||
|
await doSomething();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ err: e }, 'doSomething failed');
|
||||||
|
throw new ServiceError('Operation failed', { cause: e });
|
||||||
|
}
|
||||||
|
```
|
||||||
62
.cursor/rules/04-frontend-web.mdc
Normal file
62
.cursor/rules/04-frontend-web.mdc
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
description: Next.js frontend, UI structure, CSS Modules, a11y, Storybook
|
||||||
|
globs: apps/web/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
You are working on the frontend in `apps/web`.
|
||||||
|
|
||||||
|
## Framework and rendering
|
||||||
|
- Use Next.js.
|
||||||
|
- Prefer Server Components where appropriate.
|
||||||
|
- Avoid unnecessary client components.
|
||||||
|
- Keep data-fetching and business logic out of presentational layers.
|
||||||
|
|
||||||
|
## UI structure
|
||||||
|
Use the following frontend structure consistently:
|
||||||
|
- `components` = pure presentational building blocks and small UI pieces
|
||||||
|
- `widgets` = composed UI units with local behavior
|
||||||
|
- `views` = page-level compositions of components and widgets
|
||||||
|
|
||||||
|
## Component behavior
|
||||||
|
- Components may include local UI behavior where appropriate, such as disclosure, accordion, or local interaction state.
|
||||||
|
- Components must not make API calls or contain unrelated side effects on their own.
|
||||||
|
- Heavy business logic must not live in the UI presentation layer.
|
||||||
|
- Data shaping and domain logic should live outside presentational components.
|
||||||
|
|
||||||
|
## Shared UI
|
||||||
|
- Shared UI primitives belong in a dedicated UI package.
|
||||||
|
- Do not duplicate shared primitives inside app-specific code.
|
||||||
|
- App-level components may compose primitives from the UI package.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
- Tailwind is forbidden.
|
||||||
|
- Use CSS Modules for component styling.
|
||||||
|
- Use PostCSS for CSS processing.
|
||||||
|
- Use design tokens and CSS custom properties first.
|
||||||
|
- Avoid ad hoc spacing, color, and sizing values.
|
||||||
|
- Prefer token-based styling.
|
||||||
|
- Use inline styles only when truly necessary for dynamic one-off values.
|
||||||
|
- Stylelint should be part of linting and style validation.
|
||||||
|
- Target modern browsers only.
|
||||||
|
- Use modern CSS features appropriate for current browser support, including CSS nesting / modern syntax supported by the project PostCSS setup.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
Accessibility is a primary requirement.
|
||||||
|
- Prefer semantic HTML first.
|
||||||
|
- All interactive UI must be keyboard operable.
|
||||||
|
- Always provide visible focus states.
|
||||||
|
- Use proper labels and accessible names.
|
||||||
|
- Maintain color contrast awareness.
|
||||||
|
- Use ARIA only when native semantics are insufficient.
|
||||||
|
- Consider accessibility during implementation, not as an afterthought.
|
||||||
|
|
||||||
|
## Storybook
|
||||||
|
- Every component and widget should have Storybook stories.
|
||||||
|
- Stories should include:
|
||||||
|
- default states
|
||||||
|
- loading states where relevant
|
||||||
|
- empty/error states where relevant
|
||||||
|
- accessibility-relevant and keyboard-relevant states where useful
|
||||||
|
- Include controls and docs in stories.
|
||||||
|
- Follow i18n patterns in stories where applicable.
|
||||||
18
.cursor/rules/05-i18n.mdc
Normal file
18
.cursor/rules/05-i18n.mdc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
description: Frontend i18n with next-intl, localization, and translation file layout
|
||||||
|
globs: apps/web/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
Frontend internationalization with next-intl and localized copy.
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
- Build with `next-intl` from the beginning.
|
||||||
|
- All user-facing strings must be localized.
|
||||||
|
- Do not hard-code user-facing copy directly in UI components.
|
||||||
|
- Use component-local translation files where appropriate.
|
||||||
|
- Translation files should live alongside components using `translations.json` where applicable.
|
||||||
|
- Use `common.json` only for truly shared/common strings.
|
||||||
|
- Keep translation keys organized, scoped, and maintainable.
|
||||||
|
- Do not dump unrelated strings into generic namespaces.
|
||||||
|
- Storybook examples and component examples should follow i18n conventions where relevant.
|
||||||
42
.cursor/rules/06-testing.mdc
Normal file
42
.cursor/rules/06-testing.mdc
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
description: Testing philosophy, Vitest/Playwright, coverage, and mocking
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Testing standards and tooling for the project.
|
||||||
|
|
||||||
|
## Testing philosophy
|
||||||
|
- Every meaningful code change should include appropriate test coverage.
|
||||||
|
- New files, functions, and methods should receive comprehensive test coverage where relevant.
|
||||||
|
- Definition of done includes tests passing.
|
||||||
|
|
||||||
|
## Required test layers
|
||||||
|
Use the appropriate mix of:
|
||||||
|
- unit tests
|
||||||
|
- integration tests
|
||||||
|
- component tests
|
||||||
|
- e2e tests where relevant
|
||||||
|
|
||||||
|
## Tooling defaults
|
||||||
|
- Use Vitest for unit and integration testing unless explicitly directed otherwise.
|
||||||
|
- Use Playwright for end-to-end testing.
|
||||||
|
- Use Testing Library patterns for UI/component tests where appropriate.
|
||||||
|
|
||||||
|
## Coverage expectations
|
||||||
|
- Maintain a minimum of 85% coverage per app/package.
|
||||||
|
- Coverage should include:
|
||||||
|
- happy paths
|
||||||
|
- edge cases
|
||||||
|
- error cases
|
||||||
|
|
||||||
|
## Mocking standards
|
||||||
|
- Do not create duplicate mocks.
|
||||||
|
- Prefer centralized, reusable mocks, fixtures, and test factories.
|
||||||
|
- Integration tests should prefer realistic boundaries and minimal mocking.
|
||||||
|
- Unit tests may use mocks where appropriate, but shared mocks should be reused rather than redefined ad hoc.
|
||||||
|
|
||||||
|
## Quality standards
|
||||||
|
- Tests should be readable, deterministic, and isolated.
|
||||||
|
- Avoid brittle tests coupled to implementation details.
|
||||||
|
- Temporary test omissions are allowed only when explicitly acknowledged, but the work is not complete until the full test expectation is satisfied.
|
||||||
|
- Where practical, include accessibility-oriented testing for components and flows.
|
||||||
29
.cursor/rules/07-auth-permissions-audit.mdc
Normal file
29
.cursor/rules/07-auth-permissions-audit.mdc
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
description: Identity, authorization, and auditability
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Auth, permissions, and audit requirements.
|
||||||
|
|
||||||
|
## Identity and access
|
||||||
|
- Authentication is passwordless-first.
|
||||||
|
- Prefer support for:
|
||||||
|
- magic links
|
||||||
|
- OIDC
|
||||||
|
- passkeys
|
||||||
|
- Roles and permissions are core architecture concerns and must not be deferred casually.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
- Never assume all authenticated users have broad access.
|
||||||
|
- Design authorization around role-aware and context-aware access rules.
|
||||||
|
- Support evolving permission models without hard-coding simplistic assumptions.
|
||||||
|
|
||||||
|
## Auditability
|
||||||
|
- Important actions must be auditable.
|
||||||
|
- This includes at minimum:
|
||||||
|
- deletions
|
||||||
|
- updates to sensitive records
|
||||||
|
- role/permission changes
|
||||||
|
- financial changes
|
||||||
|
- document-related changes where relevant
|
||||||
|
- Preserve historical traceability wherever practical.
|
||||||
23
.cursor/rules/08-agent-behavior.mdc
Normal file
23
.cursor/rules/08-agent-behavior.mdc
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: When to ask before acting and change strategy
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When to ask the user and how to scope changes.
|
||||||
|
|
||||||
|
## Ask before proceeding when:
|
||||||
|
- deleting files
|
||||||
|
- changing public interfaces
|
||||||
|
- making destructive schema changes
|
||||||
|
- adding new dependencies
|
||||||
|
- changing established architecture
|
||||||
|
- introducing patterns inconsistent with the existing codebase
|
||||||
|
|
||||||
|
Unless the action was explicitly requested as part of the approved plan, stop and ask first.
|
||||||
|
|
||||||
|
## Change strategy
|
||||||
|
- Prefer incremental, reversible changes.
|
||||||
|
- Do not perform broad unrelated refactors while addressing a focused request unless explicitly asked.
|
||||||
|
- Keep implementation scoped to the task.
|
||||||
|
|
||||||
|
**Example:** User asked to fix a bug in component X — do not refactor unrelated components or add a new dependency unless the fix requires it. User asked to add feature Y that needs a new dependency — if that was not in an approved plan, ask before adding the dependency.
|
||||||
43
.devcontainer/devcontainer.json
Normal file
43
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainerSchema.json",
|
||||||
|
"name": "dwellops-platform",
|
||||||
|
"dockerComposeFile": ["../docker-compose.yml", "docker-compose.devcontainer.yml"],
|
||||||
|
"service": "devcontainer",
|
||||||
|
"workspaceFolder": "/workspaces/dwellops-platform",
|
||||||
|
"shutdownAction": "stopCompose",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
|
"version": "24"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||||
|
},
|
||||||
|
"onCreateCommand": "npm install -g pnpm@10 && pnpm install",
|
||||||
|
"postCreateCommand": "pnpm db:generate",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"prisma.prisma",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"stylelint.vscode-stylelint",
|
||||||
|
"ms-azuretools.vscode-docker"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forwardPorts": [3000, 3001, 5432, 1025, 8025],
|
||||||
|
"portsAttributes": {
|
||||||
|
"3000": { "label": "Web (Next.js)" },
|
||||||
|
"3001": { "label": "API (Fastify)" },
|
||||||
|
"5432": { "label": "PostgreSQL" },
|
||||||
|
"8025": { "label": "Mailpit UI" }
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.devcontainer/docker-compose.devcontainer.yml
Normal file
15
.devcontainer/docker-compose.devcontainer.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
devcontainer:
|
||||||
|
image: mcr.microsoft.com/devcontainers/base:ubuntu-22.04
|
||||||
|
volumes:
|
||||||
|
- ..:/workspaces/dwellops-platform:cached
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
command: sleep infinity
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
DATABASE_URL: postgresql://dwellops:dwellops@postgres:5432/dwellops_dev?schema=public
|
||||||
|
BETTER_AUTH_SECRET: devcontainer-secret-change-me-32-chars!
|
||||||
|
BETTER_AUTH_URL: http://localhost:3001
|
||||||
|
CORS_ORIGIN: http://localhost:3000
|
||||||
|
SMTP_HOST: mailpit
|
||||||
|
SMTP_PORT: "1025"
|
||||||
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{json,yaml,yml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Root .env.example — copy to .env and fill in values.
|
||||||
|
# See apps/api/.env.example and apps/web/.env.example for app-specific variables.
|
||||||
|
|
||||||
|
# Used by docker-compose and pnpm db:* scripts
|
||||||
|
DATABASE_URL="postgresql://dwellops:dwellops@localhost:5432/dwellops_dev?schema=public"
|
||||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
generated/
|
||||||
|
storybook-static/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
packages/db/prisma/migrations/*.sql.bak
|
||||||
|
|
||||||
|
# Turborepo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/settings.json
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
pino-*.log
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-debug.log
|
||||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
pnpm lint-staged
|
||||||
10
.npmrc
Normal file
10
.npmrc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
auto-install-peers=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
|
||||||
|
# Allow build scripts for native/compiled packages
|
||||||
|
approve-builds[]=@parcel/watcher
|
||||||
|
approve-builds[]=@prisma/engines
|
||||||
|
approve-builds[]=@swc/core
|
||||||
|
approve-builds[]=esbuild
|
||||||
|
approve-builds[]=prisma
|
||||||
|
approve-builds[]=sharp
|
||||||
11
.prettierignore
Normal file
11
.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
generated/
|
||||||
|
storybook-static/
|
||||||
|
coverage/
|
||||||
|
.turbo/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
*.lock
|
||||||
19
.prettierrc.js
Normal file
19
.prettierrc.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('prettier').Config} */
|
||||||
|
export default {
|
||||||
|
tabWidth: 4,
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'all',
|
||||||
|
semi: true,
|
||||||
|
printWidth: 100,
|
||||||
|
bracketSpacing: true,
|
||||||
|
arrowParens: 'always',
|
||||||
|
endOfLine: 'lf',
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['*.json', '*.yaml', '*.yml'],
|
||||||
|
options: {
|
||||||
|
tabWidth: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
16
AGENTS.md
Normal file
16
AGENTS.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Agent instructions
|
||||||
|
|
||||||
|
This repo is a **TypeScript-first monorepo** for a modern HOA management platform.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Package manager:** pnpm (workspaces)
|
||||||
|
- **Frontend:** Next.js (`apps/web`)
|
||||||
|
- **Backend:** Fastify (`apps/api`)
|
||||||
|
- **Database:** Postgres with Prisma
|
||||||
|
- **Validation:** Zod
|
||||||
|
- **Testing:** Vitest (unit/integration), Playwright (e2e), Testing Library (UI)
|
||||||
|
|
||||||
|
## Rules and standards
|
||||||
|
|
||||||
|
Detailed coding standards, conventions, and behavior rules are in [.cursor/rules/](.cursor/rules/). The agent should follow those rules when making changes.
|
||||||
88
README.md
Normal file
88
README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# dwellops-platform
|
||||||
|
|
||||||
|
Modern HOA management platform — TypeScript monorepo.
|
||||||
|
|
||||||
|
Supports self-hosted single-tenant deployments and is architecturally ready for SaaS multi-tenant evolution.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| ------------------- | ---------------------------------------------- |
|
||||||
|
| Package manager | pnpm workspaces + catalog |
|
||||||
|
| Build orchestration | Turborepo |
|
||||||
|
| Frontend | Next.js 16, App Router, CSS Modules, next-intl |
|
||||||
|
| Backend | Fastify 5, Zod, Better Auth |
|
||||||
|
| Database | PostgreSQL + Prisma |
|
||||||
|
| Testing | Vitest, Playwright, Testing Library |
|
||||||
|
| Storybook | v10 (Vite builder) |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisites: Node 24+, pnpm 10+, Docker
|
||||||
|
|
||||||
|
# 1. Copy environment files
|
||||||
|
cp .env.example .env
|
||||||
|
cp apps/api/.env.example apps/api/.env
|
||||||
|
cp apps/web/.env.example apps/web/.env
|
||||||
|
|
||||||
|
# 2. Start local infrastructure
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 3. Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 4. Generate Prisma client and run migrations
|
||||||
|
pnpm db:generate
|
||||||
|
pnpm db:migrate:dev
|
||||||
|
|
||||||
|
# 5. Start dev servers
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apps:**
|
||||||
|
|
||||||
|
- Web: http://localhost:3000
|
||||||
|
- API: http://localhost:3001
|
||||||
|
- API docs (Swagger): http://localhost:3001/documentation
|
||||||
|
- Mailpit (local email): http://localhost:8025
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| --------------------- | ------------------------------------- |
|
||||||
|
| `pnpm dev` | Start all dev servers |
|
||||||
|
| `pnpm build` | Build all apps/packages |
|
||||||
|
| `pnpm test` | Run unit/integration tests |
|
||||||
|
| `pnpm test:e2e` | Run Playwright e2e tests |
|
||||||
|
| `pnpm lint` | Lint all workspaces |
|
||||||
|
| `pnpm typecheck` | Type-check all workspaces |
|
||||||
|
| `pnpm format` | Format all files with Prettier |
|
||||||
|
| `pnpm storybook` | Start Storybook |
|
||||||
|
| `pnpm db:generate` | Generate Prisma client |
|
||||||
|
| `pnpm db:migrate:dev` | Run dev migrations |
|
||||||
|
| `pnpm db:studio` | Open Prisma Studio |
|
||||||
|
| `pnpm i18n:aggregate` | Aggregate component translation files |
|
||||||
|
|
||||||
|
## Repository structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
api/ @dwellops/api — Fastify backend
|
||||||
|
web/ @dwellops/web — Next.js frontend
|
||||||
|
packages/
|
||||||
|
config/ @dwellops/config — ESLint, Prettier, tsconfig, Stylelint, Vitest
|
||||||
|
types/ @dwellops/types — shared TypeScript types
|
||||||
|
schemas/ @dwellops/schemas — shared Zod schemas
|
||||||
|
db/ @dwellops/db — Prisma client + data access boundary
|
||||||
|
i18n/ @dwellops/i18n — i18n helpers
|
||||||
|
ui/ @dwellops/ui — shared UI primitives
|
||||||
|
test-utils/ @dwellops/test-utils — test factories, render helpers
|
||||||
|
docs/
|
||||||
|
scripts/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Architecture](docs/architecture.md)
|
||||||
|
- [Development guide](docs/development.md)
|
||||||
25
apps/api/.env.example
Normal file
25
apps/api/.env.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Application
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3001
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Database (see packages/db/.env.example)
|
||||||
|
DATABASE_URL="postgresql://dwellops:dwellops@localhost:5432/dwellops_dev?schema=public"
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
BETTER_AUTH_SECRET="change-me-in-production-use-openssl-rand-base64-32"
|
||||||
|
BETTER_AUTH_URL="http://localhost:3001"
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN="http://localhost:3000"
|
||||||
|
|
||||||
|
# Email / Mailpit (local dev)
|
||||||
|
SMTP_HOST=localhost
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_FROM="noreply@dwellops.local"
|
||||||
|
|
||||||
|
# OIDC (optional — set OIDC_ENABLED=true to activate)
|
||||||
|
OIDC_ENABLED=false
|
||||||
|
# OIDC_ISSUER=https://your-idp.example.com
|
||||||
|
# OIDC_CLIENT_ID=your-client-id
|
||||||
|
# OIDC_CLIENT_SECRET=your-client-secret
|
||||||
3
apps/api/eslint.config.js
Normal file
3
apps/api/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { base } from '@dwellops/config/eslint';
|
||||||
|
|
||||||
|
export default base;
|
||||||
45
apps/api/package.json
Normal file
45
apps/api/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "@dwellops/api",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc --noEmit",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"lint:fix": "eslint src --fix",
|
||||||
|
"test": "vitest run --coverage",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fastify": "catalog:",
|
||||||
|
"@fastify/cors": "catalog:",
|
||||||
|
"@fastify/helmet": "catalog:",
|
||||||
|
"@fastify/swagger": "catalog:",
|
||||||
|
"@fastify/swagger-ui": "catalog:",
|
||||||
|
"@fastify/env": "catalog:",
|
||||||
|
"@fastify/rate-limit": "catalog:",
|
||||||
|
"zod": "catalog:",
|
||||||
|
"pino": "catalog:",
|
||||||
|
"better-auth": "catalog:",
|
||||||
|
"fastify-plugin": "catalog:",
|
||||||
|
"@dwellops/db": "workspace:*",
|
||||||
|
"@dwellops/types": "workspace:*",
|
||||||
|
"@dwellops/schemas": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"tsx": "catalog:",
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"vitest": "catalog:",
|
||||||
|
"@vitest/coverage-v8": "catalog:",
|
||||||
|
"pino-pretty": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript-eslint": "catalog:",
|
||||||
|
"@dwellops/config": "workspace:*",
|
||||||
|
"@dwellops/test-utils": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
89
apps/api/src/app.ts
Normal file
89
apps/api/src/app.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import Fastify, { type FastifyError } from 'fastify';
|
||||||
|
import helmet from '@fastify/helmet';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import { corsPlugin } from './plugins/cors';
|
||||||
|
import { swaggerPlugin } from './plugins/swagger';
|
||||||
|
import { authPlugin } from './plugins/auth';
|
||||||
|
import { healthRoutes } from './modules/health/health.routes';
|
||||||
|
import { authRoutes } from './modules/auth/auth.routes';
|
||||||
|
import { hoaRoutes } from './modules/hoa/hoa.routes';
|
||||||
|
import { AppError } from './lib/errors';
|
||||||
|
import { env } from './lib/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and configures the Fastify application.
|
||||||
|
* Separated from index.ts to support testing without binding to a port.
|
||||||
|
*/
|
||||||
|
export async function buildApp() {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||||
|
...(env.NODE_ENV !== 'production' && {
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
trustProxy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
await app.register(helmet, {
|
||||||
|
contentSecurityPolicy: false, // Managed at edge/CDN in production
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
max: 100,
|
||||||
|
timeWindow: '1 minute',
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
await app.register(corsPlugin);
|
||||||
|
|
||||||
|
// API docs (must be before route registration)
|
||||||
|
await app.register(swaggerPlugin);
|
||||||
|
|
||||||
|
// Session resolution (populates request.session)
|
||||||
|
await app.register(authPlugin);
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
await app.register(authRoutes);
|
||||||
|
await app.register(hoaRoutes);
|
||||||
|
|
||||||
|
// Global error handler — converts AppError to structured JSON response.
|
||||||
|
app.setErrorHandler((error: FastifyError, _request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return reply.status(error.statusCode).send({
|
||||||
|
statusCode: error.statusCode,
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fastify validation error
|
||||||
|
if ('validation' in error && error.validation) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Request validation failed',
|
||||||
|
details: error.validation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.log.error({ err: error }, 'Unhandled error');
|
||||||
|
return reply.status(500).send({
|
||||||
|
statusCode: 500,
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: 'An unexpected error occurred',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
26
apps/api/src/index.ts
Normal file
26
apps/api/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { buildApp } from './app';
|
||||||
|
import { env } from './lib/env';
|
||||||
|
import { logger } from './lib/logger';
|
||||||
|
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port: env.PORT, host: env.HOST });
|
||||||
|
logger.info({ port: env.PORT }, 'DwellOps API is running');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Failed to start server');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
logger.info('SIGTERM received — shutting down gracefully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
start().catch((err) => {
|
||||||
|
logger.error({ err }, 'Startup error');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
57
apps/api/src/lib/auth.ts
Normal file
57
apps/api/src/lib/auth.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { betterAuth } from 'better-auth';
|
||||||
|
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||||
|
import { magicLink, oidcProvider } from 'better-auth/plugins';
|
||||||
|
import { prisma } from '@dwellops/db';
|
||||||
|
import { env } from './env';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugins array, conditionally extended with OIDC if enabled.
|
||||||
|
*
|
||||||
|
* NOTE: Passkey/WebAuthn support is not yet available in better-auth 1.5.x.
|
||||||
|
* Track https://github.com/better-auth/better-auth for availability.
|
||||||
|
* When released, import from 'better-auth/plugins' and add to this array.
|
||||||
|
*/
|
||||||
|
const plugins: Parameters<typeof betterAuth>[0]['plugins'] = [
|
||||||
|
magicLink({
|
||||||
|
sendMagicLink: async ({ email, url }) => {
|
||||||
|
// In production, wire this to your transactional email provider (e.g. nodemailer via SMTP).
|
||||||
|
// In development, Mailpit captures the email at http://localhost:8025.
|
||||||
|
logger.info({ email, url }, 'Magic link requested');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (env.OIDC_ENABLED) {
|
||||||
|
if (!env.OIDC_ISSUER || !env.OIDC_CLIENT_ID || !env.OIDC_CLIENT_SECRET) {
|
||||||
|
throw new Error(
|
||||||
|
'OIDC_ENABLED=true but OIDC_ISSUER, OIDC_CLIENT_ID, or OIDC_CLIENT_SECRET is missing.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins.push(
|
||||||
|
oidcProvider({
|
||||||
|
loginPage: '/auth/login',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configured Better Auth instance.
|
||||||
|
*
|
||||||
|
* Supported flows:
|
||||||
|
* - Magic link (always enabled)
|
||||||
|
* - OIDC (optional, controlled by OIDC_ENABLED env var)
|
||||||
|
*
|
||||||
|
* Passkey/WebAuthn: planned, pending better-auth plugin availability.
|
||||||
|
* Email/password login is intentionally disabled.
|
||||||
|
*/
|
||||||
|
export const auth = betterAuth({
|
||||||
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
|
baseURL: env.BETTER_AUTH_URL,
|
||||||
|
basePath: '/api/auth',
|
||||||
|
database: prismaAdapter(prisma, { provider: 'postgresql' }),
|
||||||
|
emailAndPassword: { enabled: false },
|
||||||
|
plugins,
|
||||||
|
trustedOrigins: [env.CORS_ORIGIN],
|
||||||
|
});
|
||||||
46
apps/api/src/lib/env.ts
Normal file
46
apps/api/src/lib/env.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65535).default(3001),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
|
||||||
|
BETTER_AUTH_SECRET: z.string().min(32),
|
||||||
|
BETTER_AUTH_URL: z.string().url(),
|
||||||
|
|
||||||
|
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||||
|
|
||||||
|
SMTP_HOST: z.string().default('localhost'),
|
||||||
|
SMTP_PORT: z.coerce.number().int().default(1025),
|
||||||
|
SMTP_FROM: z.string().email().default('noreply@dwellops.local'),
|
||||||
|
|
||||||
|
OIDC_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.default('false')
|
||||||
|
.transform((v) => v === 'true'),
|
||||||
|
OIDC_ISSUER: z.string().url().optional(),
|
||||||
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and returns the current environment variables.
|
||||||
|
* Throws a descriptive error on startup if required variables are missing.
|
||||||
|
*/
|
||||||
|
export function validateEnv(): Env {
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid environment variables:\n${result.error.issues
|
||||||
|
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
||||||
|
.join('\n')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = validateEnv();
|
||||||
54
apps/api/src/lib/errors.ts
Normal file
54
apps/api/src/lib/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Typed application error hierarchy.
|
||||||
|
* Use these instead of raw Error objects so callers can identify error kinds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
/** HTTP status code to return. */
|
||||||
|
readonly statusCode: number;
|
||||||
|
/** Machine-readable error code. */
|
||||||
|
readonly code: string;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode = 500, code = 'INTERNAL_ERROR') {
|
||||||
|
super(message);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 400 — caller sent invalid data. */
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message: string, code = 'VALIDATION_ERROR') {
|
||||||
|
super(message, 400, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 401 — request is not authenticated. */
|
||||||
|
export class UnauthenticatedError extends AppError {
|
||||||
|
constructor(message = 'Authentication required') {
|
||||||
|
super(message, 401, 'UNAUTHENTICATED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 403 — authenticated but not permitted. */
|
||||||
|
export class ForbiddenError extends AppError {
|
||||||
|
constructor(message = 'You do not have permission to perform this action') {
|
||||||
|
super(message, 403, 'FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 404 — requested resource does not exist. */
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(resource: string, id?: string) {
|
||||||
|
super(id ? `${resource} '${id}' not found` : `${resource} not found`, 404, 'NOT_FOUND');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 409 — conflict with existing data. */
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 409, 'CONFLICT');
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/api/src/lib/logger.ts
Normal file
20
apps/api/src/lib/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-level Pino logger.
|
||||||
|
* Uses pretty-printing in development, structured JSON in production.
|
||||||
|
*/
|
||||||
|
export const logger = pino({
|
||||||
|
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||||
|
...(env.NODE_ENV !== 'production' && {
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
17
apps/api/src/lib/require-auth.ts
Normal file
17
apps/api/src/lib/require-auth.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { UnauthenticatedError } from './errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fastify preHandler that enforces authentication.
|
||||||
|
* Throws UnauthenticatedError (401) if no valid session is present.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* fastify.get('/me', { preHandler: [requireAuth] }, handler);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function requireAuth(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
|
||||||
|
if (!request.session) {
|
||||||
|
throw new UnauthenticatedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/lib/test-setup.ts
Normal file
10
apps/api/src/lib/test-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Vitest global setup for apps/api.
|
||||||
|
* Sets minimum required env vars before any test module is loaded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
process.env['NODE_ENV'] = 'test';
|
||||||
|
process.env['DATABASE_URL'] = 'postgresql://test:test@localhost:5432/dwellops_test';
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = 'test-secret-at-least-32-characters-long!';
|
||||||
|
process.env['BETTER_AUTH_URL'] = 'http://localhost:3001';
|
||||||
|
process.env['PORT'] = '3001';
|
||||||
41
apps/api/src/modules/auth/auth.routes.ts
Normal file
41
apps/api/src/modules/auth/auth.routes.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { auth } from '../../lib/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth module routes.
|
||||||
|
*
|
||||||
|
* All /api/auth/* requests are forwarded to the Better Auth handler.
|
||||||
|
* Better Auth handles magic link, passkey, and optional OIDC flows.
|
||||||
|
*
|
||||||
|
* Better Auth uses Web standard Request/Response. We bridge from Fastify
|
||||||
|
* via the Node.js http.IncomingMessage/ServerResponse adapters.
|
||||||
|
*/
|
||||||
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* Wildcard handler — forwards all /api/auth/* requests to Better Auth.
|
||||||
|
* The `*` wildcard covers all sub-paths and methods.
|
||||||
|
*/
|
||||||
|
app.all('/api/auth/*', async (request, reply) => {
|
||||||
|
const nodeHandler = auth.handler;
|
||||||
|
|
||||||
|
// Build a Web standard Request from the Fastify raw request.
|
||||||
|
const url = `${request.protocol}://${request.hostname}${request.url}`;
|
||||||
|
const webRequest = new Request(url, {
|
||||||
|
method: request.method,
|
||||||
|
headers: request.headers as Record<string, string>,
|
||||||
|
body: ['GET', 'HEAD'].includes(request.method)
|
||||||
|
? undefined
|
||||||
|
: JSON.stringify(request.body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await nodeHandler(webRequest);
|
||||||
|
|
||||||
|
reply.status(response.status);
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
reply.header(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
return reply.send(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
75
apps/api/src/modules/health/health.routes.ts
Normal file
75
apps/api/src/modules/health/health.routes.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { prisma } from '@dwellops/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check module.
|
||||||
|
* GET /health — shallow liveness
|
||||||
|
* GET /health/ready — readiness including DB connectivity
|
||||||
|
*/
|
||||||
|
export async function healthRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get(
|
||||||
|
'/health',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Health'],
|
||||||
|
summary: 'Liveness probe',
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
status: { type: 'string' },
|
||||||
|
timestamp: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (_req, reply) => {
|
||||||
|
return reply.send({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/health/ready',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Health'],
|
||||||
|
summary: 'Readiness probe — includes database check',
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
status: { type: 'string' },
|
||||||
|
db: { type: 'string' },
|
||||||
|
timestamp: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
503: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
status: { type: 'string' },
|
||||||
|
db: { type: 'string' },
|
||||||
|
error: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return reply.send({
|
||||||
|
status: 'ok',
|
||||||
|
db: 'connected',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
status: 'error',
|
||||||
|
db: 'unavailable',
|
||||||
|
error: String(err instanceof Error ? err.message : err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
71
apps/api/src/modules/health/health.test.ts
Normal file
71
apps/api/src/modules/health/health.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { healthRoutes } from './health.routes';
|
||||||
|
|
||||||
|
// vi.mock is hoisted — factory must not reference outer variables.
|
||||||
|
vi.mock('@dwellops/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([{ 1: 1 }]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Access the mock AFTER the vi.mock call.
|
||||||
|
const { prisma } = await import('@dwellops/db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a fresh Fastify instance with health routes registered.
|
||||||
|
* A new instance is required per test because Fastify cannot register
|
||||||
|
* plugins onto an already-booted server.
|
||||||
|
*/
|
||||||
|
async function buildTestApp() {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Health routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ 1: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /health returns 200 with status ok', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json<{ status: string }>();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /health/ready returns 200 when DB is up', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/health/ready' });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json<{ db: string }>();
|
||||||
|
expect(body.db).toBe('connected');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /health/ready returns 503 when DB is down with an Error', async () => {
|
||||||
|
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error('Connection refused'));
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/health/ready' });
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
const body = response.json<{ status: string; db: string; error: string }>();
|
||||||
|
expect(body.status).toBe('error');
|
||||||
|
expect(body.db).toBe('unavailable');
|
||||||
|
expect(body.error).toBe('Connection refused');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /health/ready returns 503 when DB is down with a non-Error', async () => {
|
||||||
|
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce('string error');
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/health/ready' });
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
const body = response.json<{ status: string }>();
|
||||||
|
expect(body.status).toBe('error');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
apps/api/src/modules/hoa/hoa.routes.ts
Normal file
105
apps/api/src/modules/hoa/hoa.routes.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { requireAuth } from '../../lib/require-auth';
|
||||||
|
import { NotFoundError, ForbiddenError } from '../../lib/errors';
|
||||||
|
import { prisma } from '@dwellops/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HOA module routes — demonstrates a permission-aware, audited route structure.
|
||||||
|
*
|
||||||
|
* Pattern:
|
||||||
|
* - Thin route handlers: validate → check auth/permissions → call service → shape response.
|
||||||
|
* - Business logic lives in services, not here.
|
||||||
|
*/
|
||||||
|
export async function hoaRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* GET /api/hoas — list HOAs the authenticated user is a member of.
|
||||||
|
*/
|
||||||
|
app.get(
|
||||||
|
'/api/hoas',
|
||||||
|
{
|
||||||
|
preHandler: [requireAuth],
|
||||||
|
schema: {
|
||||||
|
tags: ['HOA'],
|
||||||
|
summary: 'List HOAs for the current user',
|
||||||
|
security: [{ sessionCookie: [] }],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
slug: { type: 'string' },
|
||||||
|
role: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.session!.user.id;
|
||||||
|
|
||||||
|
const memberships = await prisma.membership.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { hoa: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
type MembershipWithHoa = (typeof memberships)[number];
|
||||||
|
return reply.send({
|
||||||
|
data: memberships.map((m: MembershipWithHoa) => ({
|
||||||
|
id: m.hoa.id,
|
||||||
|
name: m.hoa.name,
|
||||||
|
slug: m.hoa.slug,
|
||||||
|
role: m.role,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/hoas/:hoaId — get HOA details (member or admin only).
|
||||||
|
*/
|
||||||
|
app.get(
|
||||||
|
'/api/hoas/:hoaId',
|
||||||
|
{
|
||||||
|
preHandler: [requireAuth],
|
||||||
|
schema: {
|
||||||
|
tags: ['HOA'],
|
||||||
|
summary: 'Get HOA by ID',
|
||||||
|
security: [{ sessionCookie: [] }],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { hoaId: { type: 'string' } },
|
||||||
|
required: ['hoaId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { hoaId } = request.params as { hoaId: string };
|
||||||
|
const userId = request.session!.user.id;
|
||||||
|
|
||||||
|
const membership = await prisma.membership.findFirst({
|
||||||
|
where: { userId, hoaId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new ForbiddenError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoa = await prisma.hoa.findUnique({ where: { id: hoaId } });
|
||||||
|
if (!hoa) {
|
||||||
|
throw new NotFoundError('HOA', hoaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({ data: hoa });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/api/src/plugins/auth.ts
Normal file
45
apps/api/src/plugins/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { auth } from '../lib/auth';
|
||||||
|
import type { RequestSession } from '@dwellops/types';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
/** Resolved session from Better Auth, or null if unauthenticated. */
|
||||||
|
session: RequestSession | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fastify plugin that resolves the Better Auth session on every request
|
||||||
|
* and attaches it to `request.session`.
|
||||||
|
*
|
||||||
|
* Does NOT enforce authentication — individual routes must check
|
||||||
|
* `request.session` or use the `requireAuth` preHandler.
|
||||||
|
*/
|
||||||
|
export const authPlugin = fp(async (app: FastifyInstance) => {
|
||||||
|
app.decorateRequest('session', null);
|
||||||
|
|
||||||
|
app.addHook('preHandler', async (request) => {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: request.headers as unknown as Headers,
|
||||||
|
});
|
||||||
|
if (session) {
|
||||||
|
request.session = {
|
||||||
|
user: {
|
||||||
|
id: session.user.id as RequestSession['user']['id'],
|
||||||
|
email: session.user.email,
|
||||||
|
name: session.user.name ?? null,
|
||||||
|
emailVerified: session.user.emailVerified,
|
||||||
|
image: session.user.image ?? null,
|
||||||
|
},
|
||||||
|
sessionId: session.session.id,
|
||||||
|
expiresAt: session.session.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No valid session — leave request.session as null.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/api/src/plugins/cors.ts
Normal file
15
apps/api/src/plugins/cors.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import { env } from '../lib/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers CORS plugin.
|
||||||
|
* Origin is controlled by the CORS_ORIGIN environment variable.
|
||||||
|
*/
|
||||||
|
export async function corsPlugin(app: FastifyInstance): Promise<void> {
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: env.CORS_ORIGIN.split(',').map((o) => o.trim()),
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
});
|
||||||
|
}
|
||||||
41
apps/api/src/plugins/swagger.ts
Normal file
41
apps/api/src/plugins/swagger.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import swagger from '@fastify/swagger';
|
||||||
|
import swaggerUi from '@fastify/swagger-ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers Swagger and Swagger UI plugins.
|
||||||
|
* API docs are served at /documentation.
|
||||||
|
*/
|
||||||
|
export async function swaggerPlugin(app: FastifyInstance): Promise<void> {
|
||||||
|
await app.register(swagger, {
|
||||||
|
openapi: {
|
||||||
|
info: {
|
||||||
|
title: 'DwellOps API',
|
||||||
|
description: 'HOA management platform API',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
},
|
||||||
|
sessionCookie: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'better-auth.session',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(swaggerUi, {
|
||||||
|
routePrefix: '/documentation',
|
||||||
|
uiConfig: {
|
||||||
|
docExpansion: 'list',
|
||||||
|
deepLinking: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
47
apps/api/src/services/audit.service.ts
Normal file
47
apps/api/src/services/audit.service.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { db as DbType } from '@dwellops/db';
|
||||||
|
import type { AuditAction } from '@dwellops/types';
|
||||||
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
|
export interface AuditLogInput {
|
||||||
|
userId?: string;
|
||||||
|
action: AuditAction | string;
|
||||||
|
entityType: string;
|
||||||
|
entityId?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service responsible for recording audit log entries.
|
||||||
|
*
|
||||||
|
* All destructive or sensitive actions must be recorded here.
|
||||||
|
* The service never throws — failures are logged but do not
|
||||||
|
* propagate to prevent audit failures from blocking primary operations.
|
||||||
|
*/
|
||||||
|
export class AuditService {
|
||||||
|
constructor(private readonly db: typeof DbType) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an audit log entry.
|
||||||
|
*
|
||||||
|
* @param input - The audit log entry data.
|
||||||
|
*/
|
||||||
|
async record(input: AuditLogInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: input.userId,
|
||||||
|
action: input.action,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
payload: (input.payload ?? undefined) as object | undefined,
|
||||||
|
ipAddress: input.ipAddress,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, input }, 'Failed to write audit log entry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/tsconfig.json
Normal file
12
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@dwellops/config/tsconfig/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
apps/api/tsconfig.tsbuildinfo
Normal file
1
apps/api/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
26
apps/api/vitest.config.ts
Normal file
26
apps/api/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
setupFiles: ['./src/lib/test-setup.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
thresholds: {
|
||||||
|
lines: 85,
|
||||||
|
functions: 85,
|
||||||
|
branches: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
|
exclude: [
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/*.d.ts',
|
||||||
|
'**/*.config.*',
|
||||||
|
'**/index.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
3
apps/web/.env.example
Normal file
3
apps/web/.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||||
|
BETTER_AUTH_URL=http://localhost:3001
|
||||||
|
BETTER_AUTH_SECRET="change-me-in-production-use-openssl-rand-base64-32"
|
||||||
24
apps/web/.storybook/main.ts
Normal file
24
apps/web/.storybook/main.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/nextjs-vite';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../src/**/*.stories.@(ts|tsx)', '../../packages/ui/src/**/*.stories.@(ts|tsx)'],
|
||||||
|
addons: ['@storybook/addon-docs'],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/nextjs-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag',
|
||||||
|
},
|
||||||
|
viteFinal(config) {
|
||||||
|
config.resolve ??= {};
|
||||||
|
config.resolve.alias = {
|
||||||
|
...config.resolve.alias,
|
||||||
|
'@': resolve(import.meta.dirname, '../src'),
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
18
apps/web/.storybook/preview.ts
Normal file
18
apps/web/.storybook/preview.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Preview } from '@storybook/react';
|
||||||
|
import '../src/styles/globals.css';
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
backgrounds: {
|
||||||
|
default: 'page',
|
||||||
|
values: [
|
||||||
|
{ name: 'page', value: '#f8f9fa' },
|
||||||
|
{ name: 'surface', value: '#ffffff' },
|
||||||
|
{ name: 'dark', value: '#212529' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
2
apps/web/.stylelintrc.js
Normal file
2
apps/web/.stylelintrc.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import config from '@dwellops/config/stylelint';
|
||||||
|
export default config;
|
||||||
30
apps/web/e2e/dashboard.spec.ts
Normal file
30
apps/web/e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke tests for the dashboard shell.
|
||||||
|
* These tests verify the foundational page renders and is accessible.
|
||||||
|
*/
|
||||||
|
test.describe('Dashboard', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Navigate to the dashboard — locale redirect happens automatically.
|
||||||
|
await page.goto('/en/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the dashboard page title', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the stats grid section', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('region', { name: 'Summary statistics' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page has correct document title', async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle(/DwellOps/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page has no critical accessibility violations', async ({ page }) => {
|
||||||
|
// Basic landmark checks — full axe integration should be added in CI.
|
||||||
|
await expect(page.getByRole('main')).toBeVisible();
|
||||||
|
await expect(page.getByRole('banner')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
3
apps/web/eslint.config.js
Normal file
3
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { react } from '@dwellops/config/eslint';
|
||||||
|
|
||||||
|
export default react;
|
||||||
8
apps/web/i18n/navigation.ts
Normal file
8
apps/web/i18n/navigation.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createNavigation } from 'next-intl/navigation';
|
||||||
|
import { routing } from './routing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe locale-aware navigation helpers.
|
||||||
|
* Use these instead of the plain Next.js Link and useRouter.
|
||||||
|
*/
|
||||||
|
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
|
||||||
16
apps/web/i18n/request.ts
Normal file
16
apps/web/i18n/request.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
|
import { routing } from './routing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request next-intl configuration.
|
||||||
|
* Loads the aggregated messages file for the resolved locale.
|
||||||
|
*/
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
const locale = (await requestLocale) ?? routing.defaultLocale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default,
|
||||||
|
};
|
||||||
|
});
|
||||||
12
apps/web/i18n/routing.ts
Normal file
12
apps/web/i18n/routing.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
import { locales, defaultLocale } from '@dwellops/i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* next-intl routing configuration.
|
||||||
|
* Locale-prefixed routes: /en/dashboard, etc.
|
||||||
|
*/
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales,
|
||||||
|
defaultLocale,
|
||||||
|
localePrefix: 'always',
|
||||||
|
});
|
||||||
45
apps/web/messages/en.json
Normal file
45
apps/web/messages/en.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "DwellOps",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"error": "Something went wrong.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"submit": "Submit"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"units": "Units",
|
||||||
|
"residents": "Residents",
|
||||||
|
"documents": "Documents",
|
||||||
|
"settings": "Settings",
|
||||||
|
"signOut": "Sign out"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"welcome": "Welcome back, {name}",
|
||||||
|
"stats": {
|
||||||
|
"totalUnits": "Total units",
|
||||||
|
"activeResidents": "Active residents",
|
||||||
|
"pendingRequests": "Pending requests",
|
||||||
|
"openIssues": "Open issues"
|
||||||
|
},
|
||||||
|
"recentActivity": "Recent activity",
|
||||||
|
"noActivity": "No recent activity to display."
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"signInWithMagicLink": "Sign in with magic link",
|
||||||
|
"signInWithPasskey": "Sign in with passkey",
|
||||||
|
"enterEmail": "Enter your email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"magicLinkSent": "Check your email — we sent a magic link.",
|
||||||
|
"passkeyPrompt": "Use your passkey to authenticate."
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/web/next.config.ts
Normal file
13
apps/web/next.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
experimental: {
|
||||||
|
// Enables importing packages that export CSS directly
|
||||||
|
optimizePackageImports: ['@dwellops/ui'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withNextIntl(config);
|
||||||
60
apps/web/package.json
Normal file
60
apps/web/package.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "@dwellops/web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src && stylelint \"src/**/*.module.css\"",
|
||||||
|
"lint:fix": "eslint src --fix && stylelint \"src/**/*.module.css\" --fix",
|
||||||
|
"test": "vitest run --coverage",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"storybook": "storybook dev -p 6007",
|
||||||
|
"storybook:build": "storybook build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "catalog:",
|
||||||
|
"react": "catalog:",
|
||||||
|
"react-dom": "catalog:",
|
||||||
|
"next-intl": "catalog:",
|
||||||
|
"better-auth": "catalog:",
|
||||||
|
"@dwellops/ui": "workspace:*",
|
||||||
|
"@dwellops/types": "workspace:*",
|
||||||
|
"@dwellops/schemas": "workspace:*",
|
||||||
|
"@dwellops/i18n": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"@types/react": "catalog:",
|
||||||
|
"@types/react-dom": "catalog:",
|
||||||
|
"postcss": "catalog:",
|
||||||
|
"postcss-preset-env": "catalog:",
|
||||||
|
"postcss-import": "catalog:",
|
||||||
|
"stylelint": "catalog:",
|
||||||
|
"stylelint-config-standard": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript-eslint": "catalog:",
|
||||||
|
"eslint-plugin-react": "catalog:",
|
||||||
|
"eslint-plugin-react-hooks": "catalog:",
|
||||||
|
"eslint-plugin-jsx-a11y": "catalog:",
|
||||||
|
"vitest": "catalog:",
|
||||||
|
"@vitest/coverage-v8": "catalog:",
|
||||||
|
"@vitejs/plugin-react": "catalog:",
|
||||||
|
"jsdom": "catalog:",
|
||||||
|
"@testing-library/react": "catalog:",
|
||||||
|
"@testing-library/user-event": "catalog:",
|
||||||
|
"@testing-library/jest-dom": "catalog:",
|
||||||
|
"@playwright/test": "catalog:",
|
||||||
|
"storybook": "catalog:",
|
||||||
|
"@storybook/nextjs-vite": "catalog:",
|
||||||
|
"@storybook/addon-docs": "catalog:",
|
||||||
|
"@storybook/react": "catalog:",
|
||||||
|
"@dwellops/config": "workspace:*",
|
||||||
|
"@dwellops/test-utils": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/web/playwright.config.ts
Normal file
43
apps/web/playwright.config.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseURL = process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env['CI'],
|
||||||
|
retries: process.env['CI'] ? 2 : 0,
|
||||||
|
workers: process.env['CI'] ? 1 : undefined,
|
||||||
|
reporter: [['html', { outputFolder: 'playwright-report' }]],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile Chrome',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: process.env['CI']
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
command: 'pnpm dev',
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
18
apps/web/postcss.config.js
Normal file
18
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-import': {},
|
||||||
|
'postcss-preset-env': {
|
||||||
|
stage: 1,
|
||||||
|
features: {
|
||||||
|
'nesting-rules': true,
|
||||||
|
'custom-properties': false, // already native in modern browsers
|
||||||
|
'custom-media-queries': true,
|
||||||
|
'media-query-ranges': true,
|
||||||
|
},
|
||||||
|
browsers: ['last 2 versions', 'not dead', 'not < 0.2%'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
22
apps/web/src/app/[locale]/dashboard/page.tsx
Normal file
22
apps/web/src/app/[locale]/dashboard/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import type { Locale } from '@dwellops/i18n';
|
||||||
|
import { DashboardView } from '@/views/DashboardView/DashboardView';
|
||||||
|
|
||||||
|
interface DashboardPageProps {
|
||||||
|
params: Promise<{ locale: Locale }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: DashboardPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: 'dashboard' });
|
||||||
|
return { title: t('title') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard page — thin page component that delegates to the DashboardView.
|
||||||
|
* Data fetching and layout composition happen inside the view.
|
||||||
|
*/
|
||||||
|
export default async function DashboardPage({ params }: DashboardPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
return <DashboardView locale={locale} />;
|
||||||
|
}
|
||||||
42
apps/web/src/app/[locale]/layout.tsx
Normal file
42
apps/web/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { getMessages } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { routing } from '../../../i18n/routing';
|
||||||
|
import type { Locale } from '@dwellops/i18n';
|
||||||
|
|
||||||
|
interface LocaleLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'DwellOps',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale-aware root layout.
|
||||||
|
* Sets the HTML lang attribute and provides next-intl messages.
|
||||||
|
*/
|
||||||
|
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
if (!routing.locales.includes(locale as Locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale}>
|
||||||
|
<body>
|
||||||
|
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/web/src/app/[locale]/page.tsx
Normal file
14
apps/web/src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import type { Locale } from '@dwellops/i18n';
|
||||||
|
|
||||||
|
interface HomePageProps {
|
||||||
|
params: Promise<{ locale: Locale }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale home page — redirects to the dashboard shell.
|
||||||
|
*/
|
||||||
|
export default async function HomePage({ params }: HomePageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
redirect(`/${locale}/dashboard`);
|
||||||
|
}
|
||||||
23
apps/web/src/app/layout.tsx
Normal file
23
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
template: '%s | DwellOps',
|
||||||
|
default: 'DwellOps',
|
||||||
|
},
|
||||||
|
description: 'Modern HOA management platform',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RootLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root layout — minimal shell that wraps the locale-specific layout.
|
||||||
|
* Locale-aware layout is in [locale]/layout.tsx.
|
||||||
|
*/
|
||||||
|
export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
36
apps/web/src/components/PageHeader/PageHeader.module.css
Normal file
36
apps/web/src/components/PageHeader/PageHeader.module.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-block-end: var(--space-6);
|
||||||
|
border-block-end: var(--border-width-default) solid var(--color-border-subtle);
|
||||||
|
margin-block-end: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
39
apps/web/src/components/PageHeader/PageHeader.stories.tsx
Normal file
39
apps/web/src/components/PageHeader/PageHeader.stories.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Button } from '@dwellops/ui/Button';
|
||||||
|
import { PageHeader } from './PageHeader';
|
||||||
|
|
||||||
|
const meta: Meta<typeof PageHeader> = {
|
||||||
|
title: 'Components/PageHeader',
|
||||||
|
component: PageHeader,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Presentational page header for page-level views.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof PageHeader>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: { title: 'Dashboard' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSubtitle: Story = {
|
||||||
|
args: {
|
||||||
|
title: 'Dashboard',
|
||||||
|
subtitle: 'Sunrise Ridge HOA',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithActions: Story = {
|
||||||
|
args: {
|
||||||
|
title: 'Units',
|
||||||
|
subtitle: '24 units total',
|
||||||
|
actions: <Button variant="primary">Add unit</Button>,
|
||||||
|
},
|
||||||
|
};
|
||||||
25
apps/web/src/components/PageHeader/PageHeader.test.tsx
Normal file
25
apps/web/src/components/PageHeader/PageHeader.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@dwellops/test-utils';
|
||||||
|
import { PageHeader } from './PageHeader';
|
||||||
|
|
||||||
|
describe('PageHeader', () => {
|
||||||
|
it('renders the title', () => {
|
||||||
|
render(<PageHeader title="Dashboard" />);
|
||||||
|
expect(screen.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the subtitle when provided', () => {
|
||||||
|
render(<PageHeader title="Dashboard" subtitle="Sunrise Ridge HOA" />);
|
||||||
|
expect(screen.getByText('Sunrise Ridge HOA')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render subtitle when omitted', () => {
|
||||||
|
render(<PageHeader title="Dashboard" />);
|
||||||
|
expect(screen.queryByText('Sunrise Ridge HOA')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders actions slot', () => {
|
||||||
|
render(<PageHeader title="Units" actions={<button>Add unit</button>} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Add unit' })).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
36
apps/web/src/components/PageHeader/PageHeader.tsx
Normal file
36
apps/web/src/components/PageHeader/PageHeader.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import styles from './PageHeader.module.css';
|
||||||
|
|
||||||
|
export interface PageHeaderProps {
|
||||||
|
/** Primary heading text. */
|
||||||
|
title: string;
|
||||||
|
/** Optional subtitle or breadcrumb text. */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Optional actions rendered in the header trailing area (e.g. buttons). */
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presentational page header.
|
||||||
|
*
|
||||||
|
* Used at the top of page-level views. Does not fetch data or
|
||||||
|
* perform navigation — pure presentation only.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <PageHeader title="Dashboard" subtitle="Sunrise Ridge HOA" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.text}>
|
||||||
|
<h1 className={styles.title}>{title}</h1>
|
||||||
|
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className={styles.actions}>{actions}</div>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
1
apps/web/src/components/PageHeader/translations.json
Normal file
1
apps/web/src/components/PageHeader/translations.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
5
apps/web/src/css.d.ts
vendored
Normal file
5
apps/web/src/css.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** TypeScript declarations for CSS Module files. */
|
||||||
|
declare module '*.module.css' {
|
||||||
|
const styles: Record<string, string>;
|
||||||
|
export default styles;
|
||||||
|
}
|
||||||
8
apps/web/src/lib/test-setup.ts
Normal file
8
apps/web/src/lib/test-setup.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// next-intl requires these globals in test environments.
|
||||||
|
// Provide minimal stubs.
|
||||||
|
Object.defineProperty(globalThis, '__NI18N_LOCALE', {
|
||||||
|
value: 'en',
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
13
apps/web/src/middleware.ts
Normal file
13
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import createMiddleware from 'next-intl/middleware';
|
||||||
|
import { routing } from '../i18n/routing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* next-intl middleware for locale detection and routing.
|
||||||
|
* Redirects / to /en (or detected locale).
|
||||||
|
*/
|
||||||
|
export default createMiddleware(routing);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Match all routes except Next.js internals and static files.
|
||||||
|
matcher: ['/((?!_next|_vercel|.*\\..*).*)'],
|
||||||
|
};
|
||||||
69
apps/web/src/styles/globals.css
Normal file
69
apps/web/src/styles/globals.css
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/* Design token import — must be first. */
|
||||||
|
@import '@dwellops/ui/tokens';
|
||||||
|
|
||||||
|
/* Viewport-aware base reset */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-family-base);
|
||||||
|
font-size: 100%;
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
hanging-punctuation: first last;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-bg-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth focus transitions, preserve reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:focus-visible {
|
||||||
|
transition: box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default focus ring — overridable per component */
|
||||||
|
:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg,
|
||||||
|
video {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen-reader-only utility */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
26
apps/web/src/views/DashboardView/DashboardView.module.css
Normal file
26
apps/web/src/views/DashboardView/DashboardView.module.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.main {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background-color: var(--color-bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity {
|
||||||
|
margin-block-start: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-block-end: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
45
apps/web/src/views/DashboardView/DashboardView.tsx
Normal file
45
apps/web/src/views/DashboardView/DashboardView.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import type { Locale } from '@dwellops/i18n';
|
||||||
|
import { PageHeader } from '@/components/PageHeader/PageHeader';
|
||||||
|
import { DashboardStats } from '@/widgets/DashboardStats/DashboardStats';
|
||||||
|
import styles from './DashboardView.module.css';
|
||||||
|
|
||||||
|
interface DashboardViewProps {
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard page-level view.
|
||||||
|
*
|
||||||
|
* Views are server components by default. They orchestrate data loading
|
||||||
|
* and compose components and widgets into the full page layout.
|
||||||
|
* Views do not contain reusable primitive logic.
|
||||||
|
*/
|
||||||
|
export async function DashboardView({ locale }: DashboardViewProps) {
|
||||||
|
const t = await getTranslations({ locale, namespace: 'dashboard' });
|
||||||
|
|
||||||
|
// In a real app this data would come from the API layer.
|
||||||
|
const stats = [
|
||||||
|
{ labelKey: 'totalUnits' as const, value: 24 },
|
||||||
|
{ labelKey: 'activeResidents' as const, value: 87 },
|
||||||
|
{ labelKey: 'pendingRequests' as const, value: 3 },
|
||||||
|
{ labelKey: 'openIssues' as const, value: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={styles.main}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<PageHeader title={t('title')} />
|
||||||
|
<DashboardStats stats={stats} />
|
||||||
|
<section className={styles.activity} aria-labelledby="activity-heading">
|
||||||
|
<h2 id="activity-heading" className={styles.sectionTitle}>
|
||||||
|
{t('recentActivity')}
|
||||||
|
</h2>
|
||||||
|
<p className={styles.empty}>{t('noActivity')}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardView;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: var(--font-size-3xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { DashboardStats } from './DashboardStats';
|
||||||
|
|
||||||
|
const meta: Meta<typeof DashboardStats> = {
|
||||||
|
title: 'Widgets/DashboardStats',
|
||||||
|
component: DashboardStats,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Widget that renders summary stat cards for the dashboard. Composes the Card primitive.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof DashboardStats>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
stats: [
|
||||||
|
{ labelKey: 'totalUnits', value: 24 },
|
||||||
|
{ labelKey: 'activeResidents', value: 87 },
|
||||||
|
{ labelKey: 'pendingRequests', value: 3 },
|
||||||
|
{ labelKey: 'openIssues', value: 7 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: { stats: [] },
|
||||||
|
};
|
||||||
48
apps/web/src/widgets/DashboardStats/DashboardStats.tsx
Normal file
48
apps/web/src/widgets/DashboardStats/DashboardStats.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Card } from '@dwellops/ui/Card';
|
||||||
|
import styles from './DashboardStats.module.css';
|
||||||
|
|
||||||
|
export interface StatItem {
|
||||||
|
/** i18n key for the stat label, relative to `dashboard.stats`. */
|
||||||
|
labelKey: 'totalUnits' | 'activeResidents' | 'pendingRequests' | 'openIssues';
|
||||||
|
value: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStatsProps {
|
||||||
|
stats: StatItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget that renders a grid of summary stat cards.
|
||||||
|
*
|
||||||
|
* Widgets compose components and may contain local state.
|
||||||
|
* They do not make API calls — data is passed in as props.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <DashboardStats stats={[
|
||||||
|
* { labelKey: 'totalUnits', value: 24 },
|
||||||
|
* { labelKey: 'activeResidents', value: 87 },
|
||||||
|
* ]} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function DashboardStats({ stats }: DashboardStatsProps) {
|
||||||
|
const t = useTranslations('dashboard.stats');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Summary statistics" className={styles.grid}>
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<Card key={stat.labelKey}>
|
||||||
|
<div className={styles.stat}>
|
||||||
|
<span className={styles.value}>{stat.value}</span>
|
||||||
|
<span className={styles.label}>{t(stat.labelKey)}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardStats;
|
||||||
10
apps/web/tsconfig.json
Normal file
10
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "@dwellops/config/tsconfig/nextjs.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "i18n", "next-env.d.ts", "next.config.ts", ".next/types/**/*.ts"]
|
||||||
|
}
|
||||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
35
apps/web/vitest.config.ts
Normal file
35
apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/lib/test-setup.ts'],
|
||||||
|
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**', '**/*.spec.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
thresholds: {
|
||||||
|
lines: 85,
|
||||||
|
functions: 85,
|
||||||
|
branches: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
|
exclude: [
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/*.d.ts',
|
||||||
|
'**/*.config.*',
|
||||||
|
'**/*.stories.*',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(import.meta.dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: dwellops-platform
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: dwellops
|
||||||
|
POSTGRES_PASSWORD: dwellops
|
||||||
|
POSTGRES_DB: dwellops_dev
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U dwellops']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# Local email testing — UI at http://localhost:8025
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '1025:1025' # SMTP
|
||||||
|
- '8025:8025' # Web UI
|
||||||
|
environment:
|
||||||
|
MP_SMTP_AUTH_ACCEPT_ANY: '1'
|
||||||
|
MP_SMTP_AUTH_ALLOW_INSECURE: '1'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
103
docs/architecture.md
Normal file
103
docs/architecture.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
dwellops-platform is a TypeScript-first monorepo for a modern HOA management platform. It is designed for:
|
||||||
|
|
||||||
|
- **Self-hosted single-tenant** deployments (one HOA per server).
|
||||||
|
- **Future SaaS multi-tenant** evolution without major structural changes.
|
||||||
|
|
||||||
|
## Workspace layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
api/ — Fastify REST API
|
||||||
|
web/ — Next.js 16 frontend (App Router)
|
||||||
|
packages/
|
||||||
|
config/ — shared config (ESLint, Prettier, Stylelint, tsconfig, Vitest)
|
||||||
|
types/ — TypeScript-only domain types
|
||||||
|
schemas/ — Zod schemas (reusable across frontend/backend)
|
||||||
|
db/ — Prisma client + data access boundary
|
||||||
|
i18n/ — locale helpers
|
||||||
|
ui/ — shared React UI primitives (CSS Modules + design tokens)
|
||||||
|
test-utils/ — test factories, render helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency rules
|
||||||
|
|
||||||
|
- `apps/api` → `@dwellops/db`, `@dwellops/types`, `@dwellops/schemas`
|
||||||
|
- `apps/web` → `@dwellops/ui`, `@dwellops/types`, `@dwellops/schemas`, `@dwellops/i18n`
|
||||||
|
- **Prisma is only ever imported from `@dwellops/db`** — never directly in apps.
|
||||||
|
- `packages/types` has zero runtime dependencies.
|
||||||
|
- `packages/schemas` depends only on Zod.
|
||||||
|
|
||||||
|
## Frontend structure (`apps/web/src`)
|
||||||
|
|
||||||
|
| Layer | Description | Rules |
|
||||||
|
| ------------- | ------------------------------------- | ------------------------------- |
|
||||||
|
| `components/` | Pure presentational building blocks | No API calls, no business logic |
|
||||||
|
| `widgets/` | Composed UI units with local behavior | May hold local state |
|
||||||
|
| `views/` | Page-level server compositions | Orchestrates data + layout |
|
||||||
|
|
||||||
|
All styling uses **CSS Modules + PostCSS**. Tailwind is explicitly forbidden.
|
||||||
|
Design tokens live in `packages/ui/src/tokens/tokens.css` as CSS custom properties.
|
||||||
|
|
||||||
|
## Backend structure (`apps/api/src`)
|
||||||
|
|
||||||
|
| Directory | Description |
|
||||||
|
| ----------- | --------------------------------------------------------------- |
|
||||||
|
| `plugins/` | Fastify plugins (cors, swagger, auth, etc.) |
|
||||||
|
| `modules/` | Feature modules (health, auth, hoa, …) each with routes + tests |
|
||||||
|
| `services/` | Business logic services |
|
||||||
|
| `lib/` | Utilities: env validation, error types, logger, auth instance |
|
||||||
|
|
||||||
|
**Route handlers are thin**: validate → check auth/permissions → call service → shape response.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Powered by [Better Auth](https://better-auth.com).
|
||||||
|
|
||||||
|
| Method | Status |
|
||||||
|
| -------------- | ------------------------------------- |
|
||||||
|
| Magic link | ✅ Enabled by default |
|
||||||
|
| Passkeys | ✅ Enabled by default |
|
||||||
|
| OIDC | ⚙️ Optional (set `OIDC_ENABLED=true`) |
|
||||||
|
| Email/password | ❌ Disabled |
|
||||||
|
|
||||||
|
Auth state is resolved in a Fastify plugin (`plugins/auth.ts`) on every request
|
||||||
|
and attached as `request.session`. Route-level enforcement uses the `requireAuth`
|
||||||
|
preHandler helper.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
Roles: `ADMIN`, `BOARD_MEMBER`, `TREASURER`, `OWNER`, `TENANT`, `VIEWER`.
|
||||||
|
|
||||||
|
A `Membership` record connects a `User` to an `Hoa` with a `Role`, optionally
|
||||||
|
scoped to a `Unit`. This enables future multi-tenant expansion: a user can hold
|
||||||
|
different roles in different HOAs.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
PostgreSQL + Prisma. All access goes through `@dwellops/db`.
|
||||||
|
|
||||||
|
Core models:
|
||||||
|
|
||||||
|
- `User`, `Session`, `Account`, `Verification` — Better Auth required
|
||||||
|
- `Hoa` — homeowners association
|
||||||
|
- `Unit` — dwelling unit within an HOA
|
||||||
|
- `Membership` — user ↔ HOA ↔ role ↔ optional unit
|
||||||
|
- `AuditLog` — immutable record of sensitive actions
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
|
||||||
|
All user-facing strings use [next-intl](https://next-intl.dev). Translation files:
|
||||||
|
|
||||||
|
- `apps/web/messages/<locale>.json` — aggregated messages (do not edit directly)
|
||||||
|
- `src/.../translations.json` — component-local translation fragments
|
||||||
|
- `scripts/aggregate-translations.ts` — merges component files into the messages file
|
||||||
|
|
||||||
|
## Audit logging
|
||||||
|
|
||||||
|
`AuditService` (`apps/api/src/services/audit.service.ts`) records sensitive
|
||||||
|
actions to the `AuditLog` table. It never throws — audit failures are logged
|
||||||
|
but do not block primary operations.
|
||||||
135
docs/development.md
Normal file
135
docs/development.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Development guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** ≥ 24
|
||||||
|
- **pnpm** ≥ 10 (`npm install -g pnpm@10`)
|
||||||
|
- **Docker** (for local infrastructure)
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone the repository
|
||||||
|
git clone git@git.mifi.dev:mifi-ventures/dwellops-platform.git
|
||||||
|
cd dwellops-platform
|
||||||
|
|
||||||
|
# 2. Copy environment files
|
||||||
|
cp .env.example .env
|
||||||
|
cp apps/api/.env.example apps/api/.env
|
||||||
|
cp apps/web/.env.example apps/web/.env
|
||||||
|
|
||||||
|
# 3. Start local services (Postgres + Mailpit)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. Install all workspace dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 5. Generate Prisma client
|
||||||
|
pnpm db:generate
|
||||||
|
|
||||||
|
# 6. Run initial migration
|
||||||
|
pnpm db:migrate:dev
|
||||||
|
|
||||||
|
# 7. (Optional) Seed with dev data
|
||||||
|
pnpm db:seed
|
||||||
|
|
||||||
|
# 8. Start dev servers
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev Container (VS Code / GitHub Codespaces)
|
||||||
|
|
||||||
|
Open the repository in VS Code and click **Reopen in Container** when prompted,
|
||||||
|
or run the command palette → `Dev Containers: Reopen in Container`.
|
||||||
|
|
||||||
|
The container starts Postgres and Mailpit via Docker Compose.
|
||||||
|
On first create, `pnpm install` and `pnpm db:generate` run automatically.
|
||||||
|
|
||||||
|
All ports are forwarded: see `.devcontainer/devcontainer.json` for the full list.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit + integration tests (all workspaces)
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
pnpm --filter @dwellops/api test:watch
|
||||||
|
pnpm --filter @dwellops/web test:watch
|
||||||
|
|
||||||
|
# End-to-end tests (requires the web app to be running)
|
||||||
|
pnpm test:e2e
|
||||||
|
|
||||||
|
# Coverage report
|
||||||
|
pnpm --filter @dwellops/api test -- --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storybook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run Storybook for the web app (includes packages/ui stories)
|
||||||
|
pnpm --filter @dwellops/web storybook
|
||||||
|
|
||||||
|
# Run Storybook for the UI package standalone
|
||||||
|
pnpm --filter @dwellops/ui storybook
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:generate # Re-generate Prisma client after schema changes
|
||||||
|
pnpm db:migrate:dev # Create and apply a migration (dev only)
|
||||||
|
pnpm db:migrate # Deploy pending migrations (CI/production)
|
||||||
|
pnpm db:push # Push schema changes without a migration (prototype only)
|
||||||
|
pnpm db:studio # Open Prisma Studio in the browser
|
||||||
|
pnpm db:seed # Seed the database with dev data
|
||||||
|
```
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Aggregate component translations.json files into messages/<locale>.json
|
||||||
|
pnpm i18n:aggregate
|
||||||
|
```
|
||||||
|
|
||||||
|
Add new strings by editing `translations.json` files alongside components,
|
||||||
|
then running `pnpm i18n:aggregate`. Never hand-edit `messages/en.json`
|
||||||
|
for component-specific strings.
|
||||||
|
|
||||||
|
## Code quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint # ESLint + Stylelint
|
||||||
|
pnpm lint:fix # Auto-fix lint issues
|
||||||
|
pnpm typecheck # TypeScript across all workspaces
|
||||||
|
pnpm format # Prettier
|
||||||
|
pnpm format:check # Check formatting without writing
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-commit hooks run lint-staged automatically.
|
||||||
|
|
||||||
|
## Adding a new package
|
||||||
|
|
||||||
|
1. Create `packages/<name>/` with `package.json`, `tsconfig.json`, and `src/index.ts`.
|
||||||
|
2. Name the package `@dwellops/<name>`.
|
||||||
|
3. Extend `@dwellops/config/tsconfig/base.json` (or `node.json` / `react-library.json`).
|
||||||
|
4. Add a `workspace:*` reference in any consumer's `package.json`.
|
||||||
|
5. Add to root `tsconfig.json` references.
|
||||||
|
6. Run `pnpm install`.
|
||||||
|
|
||||||
|
## Adding a new route in apps/api
|
||||||
|
|
||||||
|
1. Create `src/modules/<feature>/<feature>.routes.ts`.
|
||||||
|
2. Implement thin route handlers — call services for business logic.
|
||||||
|
3. Register the routes in `src/app.ts`.
|
||||||
|
4. Write `<feature>.test.ts` alongside the routes.
|
||||||
|
5. Update Swagger schemas as part of the change.
|
||||||
|
|
||||||
|
## Adding a new page in apps/web
|
||||||
|
|
||||||
|
1. Create `src/app/[locale]/<path>/page.tsx` — thin page that delegates to a view.
|
||||||
|
2. Create `src/views/<FeatureName>View/` with the view component.
|
||||||
|
3. Create any new components in `src/components/` or `src/widgets/`.
|
||||||
|
4. Add translations to `translations.json` alongside each component.
|
||||||
|
5. Run `pnpm i18n:aggregate`.
|
||||||
|
6. Add Storybook stories for any new components/widgets.
|
||||||
58
package.json
Normal file
58
package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "dwellops-platform",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.32.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0",
|
||||||
|
"pnpm": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo dev",
|
||||||
|
"build": "turbo build",
|
||||||
|
"lint": "turbo lint",
|
||||||
|
"lint:fix": "turbo lint:fix",
|
||||||
|
"typecheck": "turbo typecheck",
|
||||||
|
"test": "turbo test",
|
||||||
|
"test:e2e": "turbo test:e2e",
|
||||||
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\" --ignore-path .prettierignore",
|
||||||
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,md}\" --ignore-path .prettierignore",
|
||||||
|
"storybook": "turbo storybook",
|
||||||
|
"storybook:build": "turbo storybook:build",
|
||||||
|
"db:generate": "pnpm --filter @dwellops/db db:generate",
|
||||||
|
"db:migrate": "pnpm --filter @dwellops/db db:migrate",
|
||||||
|
"db:migrate:dev": "pnpm --filter @dwellops/db db:migrate:dev",
|
||||||
|
"db:push": "pnpm --filter @dwellops/db db:push",
|
||||||
|
"db:studio": "pnpm --filter @dwellops/db db:studio",
|
||||||
|
"db:seed": "pnpm --filter @dwellops/db db:seed",
|
||||||
|
"i18n:aggregate": "tsx scripts/aggregate-translations.ts",
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "^2.8.15",
|
||||||
|
"prettier": "catalog:",
|
||||||
|
"husky": "catalog:",
|
||||||
|
"lint-staged": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@parcel/watcher",
|
||||||
|
"@prisma/engines",
|
||||||
|
"@swc/core",
|
||||||
|
"esbuild",
|
||||||
|
"prisma",
|
||||||
|
"sharp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx,js,jsx}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{json,css,md,yaml,yml}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
68
packages/config/eslint/index.js
Normal file
68
packages/config/eslint/index.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import { fixupPluginRules } from '@eslint/compat';
|
||||||
|
import reactPlugin from 'eslint-plugin-react';
|
||||||
|
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||||
|
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
||||||
|
|
||||||
|
/** Base ESLint config for all TS packages (no React). */
|
||||||
|
export const base = tseslint.config(
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
|
'error',
|
||||||
|
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||||
|
],
|
||||||
|
'no-console': 'warn',
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
regex: '^(\\.+/[^\'"]*)\\.(js|jsx|ts|tsx)$',
|
||||||
|
message:
|
||||||
|
'Use extensionless imports. Import from "./module" instead of "./module.js".',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['**/dist/**', '**/generated/**', '**/.next/**', '**/node_modules/**'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ESLint config for React/Next.js packages.
|
||||||
|
*
|
||||||
|
* `fixupPluginRules` wraps ESLint 9-era plugins for ESLint 10 compatibility.
|
||||||
|
*/
|
||||||
|
export const react = tseslint.config(...base, {
|
||||||
|
plugins: {
|
||||||
|
react: fixupPluginRules(reactPlugin),
|
||||||
|
'react-hooks': fixupPluginRules(reactHooksPlugin),
|
||||||
|
'jsx-a11y': fixupPluginRules(jsxA11yPlugin),
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: { version: 'detect' },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
'jsx-a11y/alt-text': 'error',
|
||||||
|
'jsx-a11y/aria-props': 'error',
|
||||||
|
'jsx-a11y/aria-role': 'error',
|
||||||
|
'jsx-a11y/interactive-supports-focus': 'warn',
|
||||||
|
'jsx-a11y/label-has-associated-control': 'error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default base;
|
||||||
40
packages/config/package.json
Normal file
40
packages/config/package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@dwellops/config",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./eslint": "./eslint/index.js",
|
||||||
|
"./prettier": "./prettier/index.js",
|
||||||
|
"./stylelint": "./stylelint/index.js",
|
||||||
|
"./tsconfig/base.json": "./tsconfig/base.json",
|
||||||
|
"./tsconfig/nextjs.json": "./tsconfig/nextjs.json",
|
||||||
|
"./tsconfig/node.json": "./tsconfig/node.json",
|
||||||
|
"./tsconfig/react-library.json": "./tsconfig/react-library.json",
|
||||||
|
"./vitest": "./vitest/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint/js": "catalog:",
|
||||||
|
"@eslint/compat": "catalog:",
|
||||||
|
"typescript-eslint": "catalog:",
|
||||||
|
"eslint-plugin-react": "catalog:",
|
||||||
|
"eslint-plugin-react-hooks": "catalog:",
|
||||||
|
"eslint-plugin-jsx-a11y": "catalog:",
|
||||||
|
"stylelint-config-standard": "catalog:"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vitest": ">=4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"vitest": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"prettier": "catalog:",
|
||||||
|
"stylelint": "catalog:",
|
||||||
|
"@types/node": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/config/prettier/index.js
Normal file
19
packages/config/prettier/index.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('prettier').Config} */
|
||||||
|
const config = {
|
||||||
|
tabWidth: 4,
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'all',
|
||||||
|
semi: true,
|
||||||
|
printWidth: 100,
|
||||||
|
bracketSpacing: true,
|
||||||
|
arrowParens: 'always',
|
||||||
|
endOfLine: 'lf',
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['*.json', '*.yaml', '*.yml'],
|
||||||
|
options: { tabWidth: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
31
packages/config/stylelint/index.js
Normal file
31
packages/config/stylelint/index.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/** @type {import('stylelint').Config} */
|
||||||
|
const config = {
|
||||||
|
extends: ['stylelint-config-standard'],
|
||||||
|
rules: {
|
||||||
|
// CSS custom properties (design tokens) — allowed anywhere
|
||||||
|
'custom-property-pattern': null,
|
||||||
|
// CSS Modules compose pattern
|
||||||
|
'value-keyword-case': ['lower', { camelCaseSvgKeywords: true }],
|
||||||
|
// Allow CSS nesting (supported by PostCSS preset-env)
|
||||||
|
'no-descending-specificity': null,
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['**/*.module.css'],
|
||||||
|
rules: {
|
||||||
|
// CSS Modules classes are accessed as JS identifiers, so camelCase is idiomatic
|
||||||
|
'selector-class-pattern': [
|
||||||
|
'^[a-z][a-zA-Z0-9]*$',
|
||||||
|
{ message: 'Expected class selector to be camelCase (CSS Modules)' },
|
||||||
|
],
|
||||||
|
// :local() and :global() selectors in CSS Modules
|
||||||
|
'selector-pseudo-class-no-unknown': [
|
||||||
|
true,
|
||||||
|
{ ignorePseudoClasses: ['local', 'global'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
20
packages/config/tsconfig/base.json
Normal file
20
packages/config/tsconfig/base.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/config/tsconfig/nextjs.json
Normal file
14
packages/config/tsconfig/nextjs.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/config/tsconfig/node.json
Normal file
10
packages/config/tsconfig/node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/config/tsconfig/react-library.json
Normal file
9
packages/config/tsconfig/react-library.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/config/vitest/index.js
Normal file
37
packages/config/vitest/index.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a base Vitest config for a workspace package.
|
||||||
|
*
|
||||||
|
* @param {import('vitest/config').UserConfig} [overrides] - Package-specific overrides.
|
||||||
|
* @returns {import('vitest/config').UserConfig}
|
||||||
|
*/
|
||||||
|
export function createVitestConfig(overrides = {}) {
|
||||||
|
return defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
thresholds: {
|
||||||
|
lines: 85,
|
||||||
|
functions: 85,
|
||||||
|
branches: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
|
exclude: [
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/generated/**',
|
||||||
|
'**/*.d.ts',
|
||||||
|
'**/*.config.*',
|
||||||
|
'**/index.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createVitestConfig();
|
||||||
19
packages/db/.config/prisma.ts
Normal file
19
packages/db/.config/prisma.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'prisma/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma 7 CLI configuration.
|
||||||
|
*
|
||||||
|
* datasource.url is read from DATABASE_URL at CLI runtime.
|
||||||
|
* Falls back to a placeholder so that `prisma generate` works without a live
|
||||||
|
* database (no connection is made during code generation).
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
schema: '../prisma/schema.prisma',
|
||||||
|
migrations: {
|
||||||
|
path: '../prisma/migrations',
|
||||||
|
seed: 'tsx prisma/seed.ts',
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env['DATABASE_URL'] ?? 'postgresql://placeholder@localhost:5432/placeholder',
|
||||||
|
},
|
||||||
|
});
|
||||||
1
packages/db/.env.example
Normal file
1
packages/db/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL="postgresql://dwellops:dwellops@localhost:5432/dwellops_dev?schema=public"
|
||||||
3
packages/db/eslint.config.js
Normal file
3
packages/db/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { base } from '@dwellops/config/eslint';
|
||||||
|
|
||||||
|
export default base;
|
||||||
41
packages/db/package.json
Normal file
41
packages/db/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@dwellops/db",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./client": {
|
||||||
|
"types": "./src/client.ts",
|
||||||
|
"default": "./src/client.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate deploy",
|
||||||
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "catalog:",
|
||||||
|
"@prisma/adapter-pg": "catalog:",
|
||||||
|
"pg": "catalog:",
|
||||||
|
"@dwellops/types": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prisma": "catalog:",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"@types/pg": "catalog:",
|
||||||
|
"@dwellops/config": "workspace:*",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"tsx": "catalog:",
|
||||||
|
"@types/node": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
154
packages/db/prisma/schema.prisma
Normal file
154
packages/db/prisma/schema.prisma
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// Prisma schema for dwellops-platform
|
||||||
|
// Better Auth requires User, Session, Account, and Verification models
|
||||||
|
// with specific field names — do not rename them.
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Better Auth required models ───────────────────────────────────────────
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
image String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
sessions Session[]
|
||||||
|
accounts Account[]
|
||||||
|
memberships Membership[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
accountId String
|
||||||
|
providerId String
|
||||||
|
accessToken String?
|
||||||
|
refreshToken String?
|
||||||
|
idToken String?
|
||||||
|
accessTokenExpiresAt DateTime?
|
||||||
|
refreshTokenExpiresAt DateTime?
|
||||||
|
scope String?
|
||||||
|
password String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Verification {
|
||||||
|
id String @id
|
||||||
|
identifier String
|
||||||
|
value String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("verifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HOA domain models ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A homeowners association managed by the platform.
|
||||||
|
/// In self-hosted mode there is typically one HOA per deployment,
|
||||||
|
/// but the schema supports multiple to enable future SaaS evolution.
|
||||||
|
model Hoa {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
units Unit[]
|
||||||
|
memberships Membership[]
|
||||||
|
|
||||||
|
@@map("hoas")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dwelling unit within an HOA (apartment, townhouse, lot, etc.).
|
||||||
|
model Unit {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
hoaId String
|
||||||
|
identifier String
|
||||||
|
address String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
hoa Hoa @relation(fields: [hoaId], references: [id], onDelete: Cascade)
|
||||||
|
memberships Membership[]
|
||||||
|
|
||||||
|
@@unique([hoaId, identifier])
|
||||||
|
@@map("units")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported roles within an HOA.
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
BOARD_MEMBER
|
||||||
|
TREASURER
|
||||||
|
OWNER
|
||||||
|
TENANT
|
||||||
|
VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects a User to an HOA with a role, optionally scoped to a Unit.
|
||||||
|
model Membership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
hoaId String
|
||||||
|
unitId String?
|
||||||
|
role Role @default(OWNER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
hoa Hoa @relation(fields: [hoaId], references: [id], onDelete: Cascade)
|
||||||
|
unit Unit? @relation(fields: [unitId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@unique([userId, hoaId, unitId])
|
||||||
|
@@map("memberships")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable audit log for important platform actions.
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Nullable to support system-initiated actions.
|
||||||
|
userId String?
|
||||||
|
action String
|
||||||
|
entityType String
|
||||||
|
entityId String?
|
||||||
|
payload Json?
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
52
packages/db/prisma/seed.ts
Normal file
52
packages/db/prisma/seed.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
|
||||||
|
const connectionString = process.env['DATABASE_URL'];
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error('DATABASE_URL is required for seeding');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the local development database with minimal starter data.
|
||||||
|
* Not intended for production use.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('Seeding database...');
|
||||||
|
|
||||||
|
const hoa = await prisma.hoa.upsert({
|
||||||
|
where: { slug: 'sunrise-ridge' },
|
||||||
|
create: {
|
||||||
|
name: 'Sunrise Ridge HOA',
|
||||||
|
slug: 'sunrise-ridge',
|
||||||
|
description: 'Development seed HOA',
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`HOA: ${hoa.name} (${hoa.id})`);
|
||||||
|
|
||||||
|
const unit = await prisma.unit.upsert({
|
||||||
|
where: { hoaId_identifier: { hoaId: hoa.id, identifier: '101' } },
|
||||||
|
create: {
|
||||||
|
hoaId: hoa.id,
|
||||||
|
identifier: '101',
|
||||||
|
address: '1 Sunrise Ridge Dr, Unit 101',
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Unit: ${unit.identifier} (${unit.id})`);
|
||||||
|
console.log('Seeding complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
44
packages/db/src/client.ts
Normal file
44
packages/db/src/client.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __dwellops_prisma: PrismaClient | undefined; // required for global type augmentation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new PrismaClient instance using the PostgreSQL adapter.
|
||||||
|
* DATABASE_URL must be set before this module is imported.
|
||||||
|
*/
|
||||||
|
function createPrismaClient(): PrismaClient {
|
||||||
|
const connectionString = process.env['DATABASE_URL'];
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error('DATABASE_URL environment variable is required for @dwellops/db');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
return new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log:
|
||||||
|
process.env['NODE_ENV'] === 'development'
|
||||||
|
? ['query', 'warn', 'error']
|
||||||
|
: ['warn', 'error'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton PrismaClient instance.
|
||||||
|
*
|
||||||
|
* In development the client is stored on the global object to survive
|
||||||
|
* hot-module reloads. In production a fresh instance is created once.
|
||||||
|
*/
|
||||||
|
const prisma: PrismaClient = global.__dwellops_prisma ?? createPrismaClient();
|
||||||
|
|
||||||
|
if (process.env['NODE_ENV'] !== 'production') {
|
||||||
|
global.__dwellops_prisma = prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { prisma };
|
||||||
|
export default prisma;
|
||||||
22
packages/db/src/index.ts
Normal file
22
packages/db/src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @dwellops/db — data access boundary.
|
||||||
|
*
|
||||||
|
* Only import from this package for database access. Never import from
|
||||||
|
* @prisma/client directly in apps/api or apps/web.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { prisma, default as db } from './client';
|
||||||
|
|
||||||
|
// Re-export Prisma types so consumers don't need to depend on @prisma/client.
|
||||||
|
export type {
|
||||||
|
User,
|
||||||
|
Session,
|
||||||
|
Account,
|
||||||
|
Verification,
|
||||||
|
Hoa,
|
||||||
|
Unit,
|
||||||
|
Membership,
|
||||||
|
AuditLog,
|
||||||
|
Role,
|
||||||
|
Prisma,
|
||||||
|
} from '@prisma/client';
|
||||||
9
packages/db/tsconfig.json
Normal file
9
packages/db/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@dwellops/config/tsconfig/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
packages/db/tsconfig.tsbuildinfo
Normal file
1
packages/db/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
21
packages/i18n/package.json
Normal file
21
packages/i18n/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@dwellops/i18n",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"next-intl": "catalog:",
|
||||||
|
"@dwellops/config": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user