Initial commit: ArchiTools modular dashboard platform
Complete Next.js 16 application with 13 fully implemented modules: Email Signature, Word XML Generator, Registratura, Dashboard, Tag Manager, IT Inventory, Address Book, Password Vault, Mini Utilities, Prompt Generator, Digital Signatures, Word Templates, and AI Chat. Includes core platform systems (module registry, feature flags, storage abstraction, i18n, theming, auth stub, tagging), 16 technical documentation files, Docker deployment config, and legacy HTML tool reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
606
docs/DATA-MODEL.md
Normal file
606
docs/DATA-MODEL.md
Normal file
@@ -0,0 +1,606 @@
|
||||
# Data Model Reference
|
||||
|
||||
> Canonical type definitions and data modeling rules for ArchiTools.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the shared data types, entity conventions, and relationship patterns used across all ArchiTools modules. Module-specific types extend these base types. All types live in TypeScript; the runtime representation is JSON stored through the storage abstraction layer.
|
||||
|
||||
---
|
||||
|
||||
## Base Entity
|
||||
|
||||
Every storable entity in the system extends `BaseEntity`:
|
||||
|
||||
```typescript
|
||||
// src/types/base.ts
|
||||
|
||||
interface BaseEntity {
|
||||
/** Unique identifier. Generated via crypto.randomUUID() or nanoid. */
|
||||
id: string;
|
||||
|
||||
/** ISO 8601 timestamp. Set once at creation, never modified. */
|
||||
createdAt: string;
|
||||
|
||||
/** ISO 8601 timestamp. Updated on every mutation. */
|
||||
updatedAt: string;
|
||||
|
||||
/** User ID of the creator. Omitted until auth is implemented. */
|
||||
createdBy?: string;
|
||||
|
||||
/** Controls who can see this entity. */
|
||||
visibility: Visibility;
|
||||
|
||||
/** Cross-module tags attached to this entity. Array of tag IDs. */
|
||||
tags: string[];
|
||||
|
||||
/** Which company owns this entity. 'group' for shared/cross-company items. */
|
||||
company: CompanyId;
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. `id` is always a string. Use `crypto.randomUUID()` for generation. Never use sequential integers (they create conflicts across clients and are not merge-safe).
|
||||
2. `createdAt` is set once during creation and must never change.
|
||||
3. `updatedAt` is set on every save operation, including the initial creation.
|
||||
4. `createdBy` is `undefined` until authentication is implemented. When auth ships, it becomes a required field on new entities. Existing entities with `undefined` are treated as created by the system.
|
||||
5. `tags` stores tag IDs (not labels). Tags are resolved through the tag service.
|
||||
6. `company` defaults to `'group'` for entities that are not company-specific.
|
||||
|
||||
---
|
||||
|
||||
## Company Model
|
||||
|
||||
```typescript
|
||||
// src/types/company.ts
|
||||
|
||||
type CompanyId = 'beletage' | 'urban-switch' | 'studii-de-teren' | 'group';
|
||||
|
||||
interface Company {
|
||||
id: CompanyId;
|
||||
name: string; // Full legal name: "Beletage SRL"
|
||||
shortName: string; // Display name: "Beletage"
|
||||
cui: string; // Romanian CUI (tax ID)
|
||||
address?: string; // Registered address
|
||||
email?: string; // Primary contact email
|
||||
phone?: string; // Primary phone number
|
||||
}
|
||||
```
|
||||
|
||||
### Company Registry
|
||||
|
||||
Defined as static configuration in `src/config/companies.ts`:
|
||||
|
||||
```typescript
|
||||
export const COMPANIES: Record<CompanyId, Company> = {
|
||||
beletage: {
|
||||
id: 'beletage',
|
||||
name: 'Beletage SRL',
|
||||
shortName: 'Beletage',
|
||||
cui: '...', // populated in config
|
||||
},
|
||||
'urban-switch': {
|
||||
id: 'urban-switch',
|
||||
name: 'Urban Switch SRL',
|
||||
shortName: 'Urban Switch',
|
||||
cui: '...',
|
||||
},
|
||||
'studii-de-teren': {
|
||||
id: 'studii-de-teren',
|
||||
name: 'Studii de Teren SRL',
|
||||
shortName: 'Studii de Teren',
|
||||
cui: '...',
|
||||
},
|
||||
group: {
|
||||
id: 'group',
|
||||
name: 'Grup Beletage',
|
||||
shortName: 'Grup',
|
||||
cui: '',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`'group'` is a virtual company representing the entire organization. Used for shared resources (templates, tags, prompts) that are not company-specific.
|
||||
|
||||
---
|
||||
|
||||
## Visibility and Roles
|
||||
|
||||
### Visibility
|
||||
|
||||
```typescript
|
||||
// src/types/visibility.ts
|
||||
|
||||
type Visibility = 'all' | 'internal' | 'admin' | 'guest';
|
||||
```
|
||||
|
||||
| Value | Who can see | Use case |
|
||||
|---|---|---|
|
||||
| `all` | Everyone including future external/guest users | Public templates, shared contacts |
|
||||
| `internal` | All authenticated internal users | Most operational data |
|
||||
| `admin` | Admin users only | Passwords, sensitive config |
|
||||
| `guest` | Guest users and above | Curated external-facing views |
|
||||
|
||||
Until authentication is implemented, all data is treated as `internal`. The visibility field is stored but not enforced at the storage layer. Enforcement will be added at the API/middleware layer when auth ships.
|
||||
|
||||
### Roles
|
||||
|
||||
```typescript
|
||||
// src/types/roles.ts
|
||||
|
||||
type Role = 'admin' | 'user' | 'viewer' | 'guest';
|
||||
```
|
||||
|
||||
| Role | Permissions |
|
||||
|---|---|
|
||||
| `admin` | Full CRUD on all entities. Access to all modules. System configuration. |
|
||||
| `user` | CRUD on own entities. Access to enabled modules. |
|
||||
| `viewer` | Read-only access to entities matching their visibility level. |
|
||||
| `guest` | Read-only access to `guest` and `all` visibility entities. Limited module access. |
|
||||
|
||||
### Future Auth Integration
|
||||
|
||||
When Authentik SSO is integrated:
|
||||
|
||||
```typescript
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
companies: CompanyId[]; // Companies this user belongs to
|
||||
modules: string[]; // Explicitly allowed modules (empty = all for role)
|
||||
}
|
||||
```
|
||||
|
||||
Users may belong to multiple companies. Entity filtering will combine role, visibility, and company membership.
|
||||
|
||||
---
|
||||
|
||||
## Tag Model
|
||||
|
||||
Tags are a cross-module resource managed by the Tag Manager module but consumed everywhere.
|
||||
|
||||
```typescript
|
||||
// src/types/tag.ts
|
||||
|
||||
interface Tag {
|
||||
/** Unique tag ID. */
|
||||
id: string;
|
||||
|
||||
/** Human-readable label. Romanian. Example: "076 Casa Copernicus" */
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Tag category for grouping.
|
||||
* Examples: 'project', 'client', 'phase', 'type', 'priority', 'domain'
|
||||
*/
|
||||
category: TagCategory;
|
||||
|
||||
/** Display color. Hex string: "#3B82F6" */
|
||||
color: string;
|
||||
|
||||
/**
|
||||
* If set, this tag is only visible/usable within the specified modules.
|
||||
* If undefined or empty, the tag is available globally.
|
||||
*/
|
||||
moduleScope?: string[];
|
||||
|
||||
/** Company this tag belongs to. 'group' for cross-company tags. */
|
||||
company: CompanyId;
|
||||
|
||||
/** Whether this tag is active. Archived tags are hidden from selectors but preserved on entities. */
|
||||
archived: boolean;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
type TagCategory =
|
||||
| 'project'
|
||||
| 'client'
|
||||
| 'phase'
|
||||
| 'type'
|
||||
| 'priority'
|
||||
| 'domain'
|
||||
| 'custom';
|
||||
```
|
||||
|
||||
### Project Reference Pattern
|
||||
|
||||
Tags with category `'project'` follow the ManicTime naming convention used in the office:
|
||||
|
||||
```
|
||||
"{number} {project name}"
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `"076 Casa Copernicus"`
|
||||
- `"081 PUZ Sector 3"`
|
||||
- `"092 Reabilitare Scoala Nr 5"`
|
||||
|
||||
The number is a sequential project identifier scoped per company. The tag label is the canonical reference; there is no separate `projectNumber` field.
|
||||
|
||||
**Parsing utility:**
|
||||
|
||||
```typescript
|
||||
// src/lib/tags/utils.ts
|
||||
|
||||
interface ParsedProjectTag {
|
||||
number: string; // "076"
|
||||
name: string; // "Casa Copernicus"
|
||||
raw: string; // "076 Casa Copernicus"
|
||||
}
|
||||
|
||||
function parseProjectTag(label: string): ParsedProjectTag | null {
|
||||
const match = label.match(/^(\d{3})\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
return { number: match[1], name: match[2], raw: label };
|
||||
}
|
||||
```
|
||||
|
||||
### Tag Relationships
|
||||
|
||||
Tags create implicit relationships between entities across modules. For example:
|
||||
|
||||
- A registratura entry tagged `"076 Casa Copernicus"` is linked to the same project as a word template with the same tag.
|
||||
- Searching by tag ID across all namespaces yields every entity related to that project.
|
||||
|
||||
This is the primary cross-module linking mechanism until a dedicated relational backend is introduced.
|
||||
|
||||
---
|
||||
|
||||
## Module-Specific Entity Types
|
||||
|
||||
Each module defines its own entity types that extend `BaseEntity`. Below is an overview of the primary entity per module.
|
||||
|
||||
### Registratura
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/types.ts
|
||||
|
||||
interface RegistryEntry extends BaseEntity {
|
||||
/** Sequential registry number per company per year. "B-2025-0042" */
|
||||
registryNumber: string;
|
||||
|
||||
/** Entry or exit. */
|
||||
direction: 'incoming' | 'outgoing';
|
||||
|
||||
/** Date the document was registered. ISO 8601 date (not datetime). */
|
||||
registryDate: string;
|
||||
|
||||
/** Document title / description. */
|
||||
title: string;
|
||||
|
||||
/** Sender (for incoming) or recipient (for outgoing). */
|
||||
correspondent: string;
|
||||
|
||||
/** Number of physical pages, if applicable. */
|
||||
pageCount?: number;
|
||||
|
||||
/** Reference to another registry entry (e.g., reply to incoming). */
|
||||
referenceTo?: string;
|
||||
|
||||
/** Free-text notes. */
|
||||
notes?: string;
|
||||
|
||||
/** Attached file references (future: MinIO object keys). */
|
||||
attachments: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### IT Inventory
|
||||
|
||||
```typescript
|
||||
// src/modules/it-inventory/types.ts
|
||||
|
||||
type DeviceType = 'laptop' | 'desktop' | 'monitor' | 'printer' | 'router' |
|
||||
'switch' | 'nas' | 'phone' | 'tablet' | 'peripheral' | 'other';
|
||||
|
||||
type DeviceStatus = 'active' | 'storage' | 'repair' | 'retired' | 'disposed';
|
||||
|
||||
interface InventoryItem extends BaseEntity {
|
||||
name: string;
|
||||
deviceType: DeviceType;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
purchaseDate?: string;
|
||||
warrantyExpiry?: string;
|
||||
assignedTo?: string;
|
||||
location?: string;
|
||||
status: DeviceStatus;
|
||||
specs?: Record<string, string>; // CPU, RAM, storage, etc.
|
||||
notes?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Address Book
|
||||
|
||||
```typescript
|
||||
// src/modules/address-book/types.ts
|
||||
|
||||
type ContactType = 'person' | 'company' | 'institution' | 'supplier';
|
||||
|
||||
interface AddressContact extends BaseEntity {
|
||||
contactType: ContactType;
|
||||
name: string;
|
||||
organization?: string;
|
||||
role?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
county?: string;
|
||||
country?: string;
|
||||
cui?: string; // For companies/institutions
|
||||
iban?: string;
|
||||
bank?: string;
|
||||
website?: string;
|
||||
notes?: string;
|
||||
favorite: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Prompt Generator
|
||||
|
||||
```typescript
|
||||
// src/modules/prompt-generator/types.ts
|
||||
|
||||
type PromptDomain = 'architecture' | 'legal' | 'technical' | 'gis' |
|
||||
'rendering' | 'urbanism' | 'bim' | 'procurement' | 'administrative';
|
||||
|
||||
type PromptTargetAI = 'text' | 'image' | 'code' | 'review' | 'rewrite';
|
||||
|
||||
interface PromptTemplate extends BaseEntity {
|
||||
name: string;
|
||||
category: string;
|
||||
domain: PromptDomain;
|
||||
description: string;
|
||||
targetAI: PromptTargetAI;
|
||||
blocks: PromptBlock[];
|
||||
variables: PromptVariable[];
|
||||
outputMode: OutputMode;
|
||||
providerProfile?: string;
|
||||
version: number;
|
||||
parentTemplateId?: string; // For cloned/forked templates
|
||||
favorite: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Email Signature
|
||||
|
||||
```typescript
|
||||
// src/modules/email-signature/types.ts
|
||||
|
||||
interface SignatureConfig extends BaseEntity {
|
||||
employeeName: string;
|
||||
jobTitle: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
/** Generated HTML output. */
|
||||
generatedHtml: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Digital Signatures
|
||||
|
||||
```typescript
|
||||
// src/modules/digital-signatures/types.ts
|
||||
|
||||
type SignatureAssetType = 'signature' | 'stamp' | 'initials';
|
||||
|
||||
interface SignatureAsset extends BaseEntity {
|
||||
name: string;
|
||||
assetType: SignatureAssetType;
|
||||
/** Base64-encoded PNG for localStorage. MinIO object key for API backend. */
|
||||
imageData: string;
|
||||
owner?: string;
|
||||
notes?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Password Vault
|
||||
|
||||
```typescript
|
||||
// src/modules/password-vault/types.ts
|
||||
|
||||
interface VaultEntry extends BaseEntity {
|
||||
service: string;
|
||||
url?: string;
|
||||
username: string;
|
||||
/** Stored as plaintext in localStorage (demo-grade security). Encrypted in API backend. */
|
||||
password: string;
|
||||
notes?: string;
|
||||
category?: string;
|
||||
lastRotated?: string;
|
||||
}
|
||||
```
|
||||
|
||||
> **Security note:** The localStorage adapter stores passwords in plaintext. This is acceptable only for internal demo use. The API backend must encrypt the `password` field at rest.
|
||||
|
||||
### Word Templates / Word XML Generators
|
||||
|
||||
```typescript
|
||||
// src/modules/word-templates/types.ts
|
||||
|
||||
type TemplateCategory = 'contract' | 'offer' | 'report' | 'letter' |
|
||||
'header' | 'certificate' | 'minutes' | 'other';
|
||||
|
||||
interface WordTemplate extends BaseEntity {
|
||||
name: string;
|
||||
category: TemplateCategory;
|
||||
description?: string;
|
||||
/** File reference: base64 for localStorage, MinIO key for API backend. */
|
||||
fileData: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
version: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Type Names
|
||||
|
||||
| Convention | Example | Used For |
|
||||
|---|---|---|
|
||||
| PascalCase | `RegistryEntry` | Interfaces, type aliases, classes |
|
||||
| camelCase | `registryNumber` | Properties, variables, functions |
|
||||
| UPPER_SNAKE_CASE | `COMPANIES` | Constants, static config objects |
|
||||
| kebab-case | `'urban-switch'` | String literal union members (IDs, slugs) |
|
||||
|
||||
### Entity Type Naming
|
||||
|
||||
- Main entity per module: descriptive noun (`RegistryEntry`, `InventoryItem`, `AddressContact`).
|
||||
- Do not prefix with module name unless ambiguity exists.
|
||||
- Supporting types (enums, sub-objects): use descriptive names scoped by context (`DeviceType`, `ContactType`).
|
||||
- Union string literal types: use the values themselves, not a wrapper. Prefer `type Direction = 'incoming' | 'outgoing'` over an enum.
|
||||
|
||||
### File Naming
|
||||
|
||||
- Type definition files: `types.ts` in the module root.
|
||||
- Shared types: `src/types/{name}.ts`.
|
||||
- Never put types in a barrel `index.ts` that also exports components (causes circular dependency issues with lazy loading).
|
||||
|
||||
---
|
||||
|
||||
## Schema Versioning
|
||||
|
||||
### Strategy
|
||||
|
||||
Each module's data schema has a version number tracked in the namespace metadata:
|
||||
|
||||
```typescript
|
||||
// Stored at key "_meta" within each namespace
|
||||
interface NamespaceMeta {
|
||||
schemaVersion: number;
|
||||
lastMigration: string;
|
||||
itemCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Registration
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/migrations.ts
|
||||
|
||||
import { defineMigrations } from '@/lib/storage/migrations';
|
||||
|
||||
export const registraturaMigrations = defineMigrations('registratura', [
|
||||
{
|
||||
version: 2,
|
||||
description: 'Add attachments array to registry entries',
|
||||
up: (data: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key === '_meta') continue;
|
||||
const entry = value as Record<string, unknown>;
|
||||
if (!entry.attachments) {
|
||||
entry.attachments = [];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
description: 'Rename sender field to correspondent',
|
||||
up: (data: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key === '_meta') continue;
|
||||
const entry = value as Record<string, unknown>;
|
||||
if ('sender' in entry) {
|
||||
entry.correspondent = entry.sender;
|
||||
delete entry.sender;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
### Migration Execution
|
||||
|
||||
On module initialization, the storage layer:
|
||||
|
||||
1. Reads `_meta.schemaVersion` for the module's namespace.
|
||||
2. Compares to the latest registered migration version.
|
||||
3. If behind, runs each migration sequentially (`version N` to `version N+1`).
|
||||
4. Updates `_meta.schemaVersion` after each successful migration.
|
||||
5. If any migration fails, the process halts and logs an error. The module should display a degraded state rather than corrupting data.
|
||||
|
||||
---
|
||||
|
||||
## Entity Relationships
|
||||
|
||||
ArchiTools uses a flat document model (not relational). Relationships are expressed through:
|
||||
|
||||
### 1. Tags (Primary Cross-Module Link)
|
||||
|
||||
Entities share project tags. Querying all namespaces for a tag ID returns every entity related to that project.
|
||||
|
||||
```
|
||||
RegistryEntry.tags: ["tag-076-casa-copernicus"]
|
||||
WordTemplate.tags: ["tag-076-casa-copernicus"]
|
||||
PromptTemplate.tags: ["tag-076-casa-copernicus"]
|
||||
```
|
||||
|
||||
### 2. Direct References
|
||||
|
||||
Some entities reference others by ID within the same namespace:
|
||||
|
||||
- `RegistryEntry.referenceTo` points to another registry entry ID.
|
||||
- `PromptTemplate.parentTemplateId` points to the template it was cloned from.
|
||||
|
||||
### 3. Company Scoping
|
||||
|
||||
The `company` field on every entity enables filtering by company. A user at Beletage sees Beletage and group entities; never Urban Switch entities (unless their role grants cross-company access).
|
||||
|
||||
### 4. Future: Explicit Project Entity
|
||||
|
||||
When the platform matures, a `Project` entity may be introduced:
|
||||
|
||||
```typescript
|
||||
interface Project extends BaseEntity {
|
||||
number: string; // "076"
|
||||
name: string; // "Casa Copernicus"
|
||||
company: CompanyId;
|
||||
status: 'active' | 'completed' | 'archived';
|
||||
client?: string; // AddressContact ID
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
```
|
||||
|
||||
At that point, tags with category `'project'` would reference a `Project.id`, and the project number/name would be the single source of truth. Until then, the tag label is the canonical project identifier.
|
||||
|
||||
---
|
||||
|
||||
## Shared Type Exports
|
||||
|
||||
All shared types are re-exported from `src/types/index.ts`:
|
||||
|
||||
```typescript
|
||||
// src/types/index.ts
|
||||
|
||||
export type { BaseEntity } from './base';
|
||||
export type { CompanyId, Company } from './company';
|
||||
export type { Visibility } from './visibility';
|
||||
export type { Role, UserProfile } from './roles';
|
||||
export type { Tag, TagCategory } from './tag';
|
||||
```
|
||||
|
||||
Module-specific types are not exported from this barrel. They are imported directly from the module:
|
||||
|
||||
```typescript
|
||||
import type { RegistryEntry } from '@/modules/registratura/types';
|
||||
```
|
||||
|
||||
This keeps the shared type surface minimal and avoids pulling module code into unrelated bundles.
|
||||
563
docs/REPO-STRUCTURE.md
Normal file
563
docs/REPO-STRUCTURE.md
Normal file
@@ -0,0 +1,563 @@
|
||||
# ArchiTools — Repository Structure Reference
|
||||
|
||||
This document describes every planned directory and key file in the ArchiTools repository. It serves as the canonical reference for where code belongs, what each directory contains, and what naming conventions to follow.
|
||||
|
||||
---
|
||||
|
||||
## Complete Directory Tree
|
||||
|
||||
```
|
||||
ArchiTools/
|
||||
├── docs/ # Internal technical documentation
|
||||
│ ├── architecture/ # Architecture design documents
|
||||
│ │ └── SYSTEM-ARCHITECTURE.md # Platform architecture overview
|
||||
│ ├── guides/ # Developer and operational guides
|
||||
│ │ ├── MODULE-DEVELOPMENT.md # How to build a new module
|
||||
│ │ ├── STORAGE-LAYER.md # Storage abstraction usage
|
||||
│ │ ├── FEATURE-FLAGS.md # Feature flag system guide
|
||||
│ │ ├── HTML-INTEGRATION.md # Legacy HTML tool integration
|
||||
│ │ ├── DOCKER-DEPLOYMENT.md # Build, deploy, and operate
|
||||
│ │ ├── CODING-STANDARDS.md # Code style and conventions
|
||||
│ │ └── UI-DESIGN-SYSTEM.md # Theme, components, layout rules
|
||||
│ └── modules/ # Module-specific design docs
|
||||
│ ├── REGISTRATURA.md # Registratura module design
|
||||
│ ├── PROMPT-GENERATOR.md # Prompt Generator module design
|
||||
│ └── ... # One doc per complex module
|
||||
│
|
||||
├── src/ # All application source code
|
||||
│ ├── app/ # Next.js App Router (routing layer)
|
||||
│ │ ├── layout.tsx # Root layout
|
||||
│ │ ├── page.tsx # Dashboard home page
|
||||
│ │ ├── globals.css # Tailwind directives + global styles
|
||||
│ │ ├── not-found.tsx # Custom 404 page
|
||||
│ │ └── (modules)/ # Route group for all modules
|
||||
│ │ ├── registratura/
|
||||
│ │ │ ├── page.tsx # Module list/main view
|
||||
│ │ │ ├── layout.tsx # Module-level layout (optional)
|
||||
│ │ │ └── [id]/
|
||||
│ │ │ └── page.tsx # Detail/edit view
|
||||
│ │ ├── email-signature/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── word-xml/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── prompt-generator/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── digital-signatures/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── password-vault/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── it-inventory/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── address-book/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── word-templates/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── tag-manager/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── mini-utilities/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ └── ai-chat/
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── core/ # Platform core systems
|
||||
│ │ ├── module-registry/
|
||||
│ │ │ ├── registry.ts # Module catalog and lookup functions
|
||||
│ │ │ ├── types.ts # ModuleConfig, ModuleCategory types
|
||||
│ │ │ └── index.ts # Public API
|
||||
│ │ ├── feature-flags/
|
||||
│ │ │ ├── flag-service.ts # Flag resolution logic
|
||||
│ │ │ ├── flag-provider.tsx # React context provider
|
||||
│ │ │ ├── use-feature-flag.ts # Hook for flag checks
|
||||
│ │ │ ├── feature-gate.tsx # Conditional render component
|
||||
│ │ │ ├── types.ts # FeatureFlag, FlagScope types
|
||||
│ │ │ └── index.ts # Public API
|
||||
│ │ ├── storage/
|
||||
│ │ │ ├── storage-service.ts # StorageService interface definition
|
||||
│ │ │ ├── storage-provider.tsx # React context provider
|
||||
│ │ │ ├── use-storage.ts # Hook for storage access
|
||||
│ │ │ ├── adapters/
|
||||
│ │ │ │ ├── local-storage.ts # localStorage adapter (current)
|
||||
│ │ │ │ ├── minio.ts # MinIO adapter (planned)
|
||||
│ │ │ │ └── database.ts # Database adapter (planned)
|
||||
│ │ │ ├── types.ts # StorageAdapter, StorageOptions types
|
||||
│ │ │ └── index.ts # Public API
|
||||
│ │ ├── tagging/
|
||||
│ │ │ ├── tag-service.ts # Tag CRUD and query logic
|
||||
│ │ │ ├── use-tags.ts # Hook for tag operations
|
||||
│ │ │ ├── types.ts # Tag, TagCategory types
|
||||
│ │ │ └── index.ts # Public API
|
||||
│ │ ├── i18n/
|
||||
│ │ │ ├── i18n-provider.tsx # React context provider
|
||||
│ │ │ ├── use-label.ts # Hook for label access
|
||||
│ │ │ ├── label.tsx # <Label> component
|
||||
│ │ │ ├── locales/
|
||||
│ │ │ │ └── ro.ts # Romanian labels (current)
|
||||
│ │ │ ├── types.ts # LabelKey, Locale types
|
||||
│ │ │ └── index.ts # Public API
|
||||
│ │ ├── theme/
|
||||
│ │ │ ├── theme-provider.tsx # Theme context with dark/light toggle
|
||||
│ │ │ ├── use-theme.ts # Hook for theme access
|
||||
│ │ │ ├── tokens.ts # Design token definitions
|
||||
│ │ │ └── index.ts # Public API
|
||||
│ │ └── auth/
|
||||
│ │ ├── auth-provider.tsx # Auth context provider (stub)
|
||||
│ │ ├── use-auth.ts # Hook for auth state
|
||||
│ │ ├── types.ts # User, UserRole, AuthContext types
|
||||
│ │ └── index.ts # Public API
|
||||
│ │
|
||||
│ ├── modules/ # Module business logic
|
||||
│ │ ├── registratura/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── entry-form.tsx # Registry entry form
|
||||
│ │ │ │ ├── entry-table.tsx # Entry list table
|
||||
│ │ │ │ └── entry-card.tsx # Entry summary card
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ │ ├── use-entries.ts # Entry CRUD state management
|
||||
│ │ │ │ └── use-entry-filters.ts
|
||||
│ │ │ ├── services/
|
||||
│ │ │ │ ├── entry-service.ts # Entry business logic
|
||||
│ │ │ │ └── validation.ts # Entry validation rules
|
||||
│ │ │ ├── types.ts # RegistryEntry, EntryStatus, etc.
|
||||
│ │ │ ├── config.ts # Module registration config
|
||||
│ │ │ └── index.ts # Public barrel export
|
||||
│ │ ├── email-signature/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── word-xml/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── prompt-generator/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── templates/ # Prompt template data files
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── digital-signatures/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── password-vault/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── it-inventory/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── address-book/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── word-templates/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── tag-manager/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── mini-utilities/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── config.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── ai-chat/
|
||||
│ │ ├── components/
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── services/
|
||||
│ │ ├── types.ts
|
||||
│ │ ├── config.ts
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── shared/ # Shared UI components and utilities
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── ui/ # shadcn/ui primitives
|
||||
│ │ │ │ ├── button.tsx
|
||||
│ │ │ │ ├── input.tsx
|
||||
│ │ │ │ ├── card.tsx
|
||||
│ │ │ │ ├── dialog.tsx
|
||||
│ │ │ │ ├── table.tsx
|
||||
│ │ │ │ ├── badge.tsx
|
||||
│ │ │ │ ├── select.tsx
|
||||
│ │ │ │ ├── tabs.tsx
|
||||
│ │ │ │ ├── toast.tsx
|
||||
│ │ │ │ ├── dropdown-menu.tsx
|
||||
│ │ │ │ └── ... # Added as needed via shadcn CLI
|
||||
│ │ │ ├── layout/ # Application shell components
|
||||
│ │ │ │ ├── app-shell.tsx # Main layout wrapper
|
||||
│ │ │ │ ├── sidebar.tsx # Navigation sidebar
|
||||
│ │ │ │ ├── header.tsx # Top header bar
|
||||
│ │ │ │ ├── footer.tsx # Optional footer
|
||||
│ │ │ │ └── breadcrumbs.tsx # Breadcrumb navigation
|
||||
│ │ │ └── common/ # Reusable domain components
|
||||
│ │ │ ├── tag-selector.tsx # Tag picker (uses tagging core)
|
||||
│ │ │ ├── company-badge.tsx # Company indicator
|
||||
│ │ │ ├── data-table.tsx # Generic sortable/filterable table
|
||||
│ │ │ ├── search-input.tsx # Debounced search field
|
||||
│ │ │ ├── confirm-dialog.tsx # Confirmation modal
|
||||
│ │ │ ├── empty-state.tsx # Empty state placeholder
|
||||
│ │ │ ├── loading-state.tsx # Loading skeleton
|
||||
│ │ │ └── copy-button.tsx # Copy-to-clipboard button
|
||||
│ │ ├── hooks/ # Shared React hooks
|
||||
│ │ │ ├── use-debounce.ts # Debounce value hook
|
||||
│ │ │ ├── use-clipboard.ts # Clipboard access hook
|
||||
│ │ │ ├── use-media-query.ts # Responsive breakpoint hook
|
||||
│ │ │ └── use-local-state.ts # Local component state with sync
|
||||
│ │ └── lib/ # Shared utility functions
|
||||
│ │ ├── utils.ts # General utilities (cn(), formatDate, etc.)
|
||||
│ │ ├── validators.ts # Common validation functions
|
||||
│ │ └── constants.ts # Shared constants
|
||||
│ │
|
||||
│ └── config/ # Global application configuration
|
||||
│ ├── modules.ts # Module registry (imports all module configs)
|
||||
│ ├── flags.ts # Feature flag defaults
|
||||
│ ├── navigation.ts # Navigation structure builder
|
||||
│ ├── companies.ts # Company definitions (Beletage, Urban Switch, Studii de Teren)
|
||||
│ └── external-tools.ts # External tool links (Stirling PDF, IT-Tools, etc.)
|
||||
│
|
||||
├── legacy/ # Original standalone HTML tools
|
||||
│ ├── emailsignature/
|
||||
│ │ └── emailsignature-config.html
|
||||
│ ├── wordXMLgenerator/
|
||||
│ │ ├── word-xml-generator-basic.html
|
||||
│ │ ├── word-xml-generator-medium.html
|
||||
│ │ └── word-xml-generator-advanced.html
|
||||
│ └── manicprojects/
|
||||
│ └── current manic time Tags.txt
|
||||
│
|
||||
├── public/ # Static assets served at root
|
||||
│ ├── favicon.ico
|
||||
│ ├── logo.svg # Platform logo
|
||||
│ └── assets/
|
||||
│ ├── company-logos/ # Company logo variants
|
||||
│ └── icons/ # Custom icons if needed
|
||||
│
|
||||
├── Dockerfile # Multi-stage production build
|
||||
├── docker-compose.yml # Service definition for deployment
|
||||
├── .env.example # Environment variable template
|
||||
├── .env.local # Local dev overrides (gitignored)
|
||||
├── next.config.ts # Next.js configuration
|
||||
├── tailwind.config.ts # Tailwind theme and plugin config
|
||||
├── tsconfig.json # TypeScript compiler configuration
|
||||
├── postcss.config.mjs # PostCSS config (Tailwind plugin)
|
||||
├── package.json # Dependencies and scripts
|
||||
├── package-lock.json # Dependency lock file
|
||||
├── components.json # shadcn/ui configuration
|
||||
├── .gitignore # Git ignore rules
|
||||
├── .eslintrc.json # ESLint configuration
|
||||
├── .prettierrc # Prettier formatting rules
|
||||
├── CONTEXT.md # Project context and requirements
|
||||
└── LICENSE # License file (if applicable)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Descriptions
|
||||
|
||||
### `docs/`
|
||||
|
||||
Internal technical documentation. Not shipped in the Docker image. Organized into three subdirectories:
|
||||
|
||||
- **`architecture/`** — System-level design documents. Describes the platform as a whole: architecture overview, data flow, deployment model, security boundaries. These documents change infrequently and are reviewed before major structural changes.
|
||||
|
||||
- **`guides/`** — Developer and operational guides. Practical, how-to oriented. Covers module development, storage usage, feature flag configuration, Docker deployment, coding standards, and UI design rules. These are the documents developers consult daily.
|
||||
|
||||
- **`modules/`** — Module-specific design documents. One file per complex module. Describes the module's data model, UI structure, business rules, integration points, and edge cases. Simple modules may not need a dedicated doc.
|
||||
|
||||
### `src/app/`
|
||||
|
||||
Next.js App Router directory. Contains **only routing concerns**: page components, layouts, and metadata. No business logic lives here. Page files are thin wrappers that import and compose components from `src/modules/` and `src/shared/`.
|
||||
|
||||
- **`layout.tsx`** — Root layout. Wraps the entire application in the provider stack (Theme, i18n, Storage, FeatureFlags, Auth). Renders the AppShell (sidebar + header + content area). This file should rarely change after initial setup.
|
||||
|
||||
- **`page.tsx`** — Dashboard home. Renders the widget grid with module entry points, quick actions, and infrastructure service links.
|
||||
|
||||
- **`globals.css`** — Tailwind's `@tailwind` directives, CSS custom properties for theming, and minimal global styles. No component-specific CSS.
|
||||
|
||||
- **`not-found.tsx`** — Custom 404 page displayed when navigating to disabled modules or invalid routes.
|
||||
|
||||
- **`(modules)/`** — Route group. The parentheses mean this directory does not add a URL segment. All module routes live here, keeping them visually grouped without nesting URLs. Each module subdirectory contains at minimum a `page.tsx`. Complex modules may include `layout.tsx` for module-level navigation (tabs, sub-routes) and dynamic route segments (`[id]/`).
|
||||
|
||||
### `src/core/`
|
||||
|
||||
Platform core systems. These are infrastructure services used by all modules. Core systems do not contain business logic for any specific module. They provide capabilities that modules consume.
|
||||
|
||||
- **`module-registry/`** — Maintains the catalog of all registered modules. Provides lookup functions (by ID, route, category), filtering (by enabled state, role, visibility), and the ordered list used by navigation. The registry reads from `src/config/modules.ts`.
|
||||
|
||||
- **`feature-flags/`** — Controls runtime activation of modules and features. Includes the flag resolution service (environment variable > config > default), React context provider, `useFeatureFlag()` hook, and `<FeatureGate>` render guard component. Flags are defined in `src/config/flags.ts`.
|
||||
|
||||
- **`storage/`** — Persistence abstraction layer. Defines the `StorageService` interface and provides adapter implementations. The `adapters/` subdirectory contains one file per storage backend. The active adapter is selected at startup based on environment configuration. All module data flows through this service.
|
||||
|
||||
- **`tagging/`** — Cross-module tagging system. Provides tag CRUD operations, tag association with entities, and tag-based querying. Tags are stored via the storage abstraction. Multiple modules (Registratura, Prompt Generator, Digital Signatures, etc.) use this service to categorize and cross-reference their entities.
|
||||
|
||||
- **`i18n/`** — Internationalization system. Currently Romanian-only, structured for future multi-language support. Labels are organized by namespace (one per module + common). The `locales/` subdirectory contains one file per language. Components access labels via `useLabel()` hook or `<Label>` component.
|
||||
|
||||
- **`theme/`** — Dark/light theme system. Provides the theme context, toggle hook, and design token definitions. Theme preference is persisted in storage. Tokens define colors, spacing, and typography values consumed by Tailwind and component styles.
|
||||
|
||||
- **`auth/`** — Authentication and authorization stub. Defines the `AuthContext` interface (`user`, `role`, `permissions`, `company`). Currently returns a default admin user. When Authentik SSO integration is implemented, this module will resolve real identity from OIDC tokens. The interface remains stable; only the provider implementation changes.
|
||||
|
||||
### `src/modules/`
|
||||
|
||||
Module business logic. Each subdirectory is a self-contained module with its own components, hooks, services, types, and configuration. Modules are the primary unit of functionality in ArchiTools.
|
||||
|
||||
**Standard module structure:**
|
||||
|
||||
| File/Directory | Purpose |
|
||||
|----------------|---------|
|
||||
| `components/` | React components specific to this module. Not shared with other modules. |
|
||||
| `hooks/` | React hooks encapsulating module state and logic. Hooks call services and expose state to components. |
|
||||
| `services/` | Pure business logic functions. Validation, transformation, computation. No React dependencies. |
|
||||
| `types.ts` | TypeScript interfaces and types for this module's domain. |
|
||||
| `config.ts` | `ModuleConfig` object exported for the module registry. Contains ID, name, route, icon, category, feature flag key, and other metadata. |
|
||||
| `index.ts` | Barrel export. Exposes the module's public API: config, key types, and any components/hooks that other parts of the app need (primarily used by route pages). |
|
||||
|
||||
**Module-specific additions:**
|
||||
|
||||
Some modules extend the standard structure:
|
||||
|
||||
- `prompt-generator/templates/` — Data-driven prompt template definitions (JSON/TS files, not UI components).
|
||||
- Modules with sub-views may have a `views/` directory alongside `components/` for page-level compositions.
|
||||
|
||||
### `src/shared/`
|
||||
|
||||
Shared UI components, hooks, and utilities used across multiple modules. Nothing module-specific lives here.
|
||||
|
||||
- **`components/ui/`** — shadcn/ui primitive components. These are added via the `npx shadcn@latest add` CLI command and may be customized. They provide the atomic building blocks: buttons, inputs, cards, dialogs, tables, badges, selects, etc. Files in this directory are generated and should be modified sparingly.
|
||||
|
||||
- **`components/layout/`** — Application shell components. The sidebar, header, footer, breadcrumbs, and the main app-shell wrapper. These components compose the persistent chrome around module content. They read from the module registry and navigation config to build menu items dynamically.
|
||||
|
||||
- **`components/common/`** — Reusable domain-aware components shared across modules. Unlike `ui/` (which are generic primitives), these components encode ArchiTools-specific patterns: tag selectors, company badges, data tables with standard filtering, confirmation dialogs, empty/loading states. Any component needed by two or more modules belongs here.
|
||||
|
||||
- **`hooks/`** — Shared React hooks for common browser interactions: debouncing, clipboard access, media queries, local component state synchronization. Module-specific hooks belong in the module's `hooks/` directory, not here.
|
||||
|
||||
- **`lib/`** — Pure utility functions and constants. Includes the `cn()` class merge utility (standard shadcn/ui pattern), date formatting, common validators, and shared constants (e.g., date formats, pagination defaults).
|
||||
|
||||
### `src/config/`
|
||||
|
||||
Global application configuration. Static configuration data that drives runtime behavior.
|
||||
|
||||
- **`modules.ts`** — Central module registry data. Imports `config.ts` from every module and exports the complete array. This is the single file that determines which modules the platform knows about.
|
||||
|
||||
- **`flags.ts`** — Feature flag default values. Maps flag keys to their default `enabled` state and scope. Environment variables can override these at runtime.
|
||||
|
||||
- **`navigation.ts`** — Navigation structure builder. Reads from the module registry, applies feature flag filtering, groups by category, and produces the ordered navigation tree consumed by the sidebar.
|
||||
|
||||
- **`companies.ts`** — Company definitions for Beletage SRL, Urban Switch SRL, and Studii de Teren SRL. Contains company ID, name, short name, color, logo path, and any per-company configuration. Used by the Registratura, Email Signature Generator, Address Book, and other multi-company modules.
|
||||
|
||||
- **`external-tools.ts`** — Links to external infrastructure services (Stirling PDF, IT-Tools, Filebrowser, N8N, Portainer, etc.) displayed on the dashboard and optionally in navigation.
|
||||
|
||||
### `legacy/`
|
||||
|
||||
Original standalone HTML tools that predate the ArchiTools platform. These files are preserved for reference and serve as the specification for React module reimplementation. They are not served by the Next.js application.
|
||||
|
||||
- **`emailsignature/`** — Email signature generator. Single HTML file with inline CSS/JS. Generates HTML email signatures for company employees.
|
||||
|
||||
- **`wordXMLgenerator/`** — Word XML content control binding generators. Three complexity tiers (basic, medium, advanced). Generate XML binding files for Microsoft Word templates.
|
||||
|
||||
- **`manicprojects/`** — ManicTime project tag definitions. Text file with time tracking tag categories.
|
||||
|
||||
When a legacy tool is fully reimplemented as a React module, the legacy file remains in this directory for archival reference. It is never deleted.
|
||||
|
||||
### `public/`
|
||||
|
||||
Static assets served at the root URL path by Next.js. Contains the favicon, platform logo, company logos, and any custom icon assets. Keep this directory minimal; most assets should be imported via the build pipeline for optimization.
|
||||
|
||||
### Root Configuration Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Dockerfile` | Multi-stage build: deps install, Next.js build, minimal runtime image. Produces a standalone Next.js server on Node.js Alpine. |
|
||||
| `docker-compose.yml` | Single-service definition for ArchiTools. References `.env` for environment variables. Defines port mapping, restart policy, and optional volume mounts. |
|
||||
| `.env.example` | Template for environment variables. Committed to the repo. Documents every variable with comments. Developers copy to `.env.local` for local development. |
|
||||
| `next.config.ts` | Next.js configuration. Enables standalone output mode (required for Docker), configures image domains, and sets any required headers. |
|
||||
| `tailwind.config.ts` | Tailwind CSS configuration. Extends the default theme with ArchiTools design tokens, custom colors, and font settings. References content paths for tree-shaking. |
|
||||
| `tsconfig.json` | TypeScript configuration. Sets strict mode, path aliases (`@/` maps to `src/`), and App Router-compatible settings. |
|
||||
| `postcss.config.mjs` | PostCSS configuration. Loads the Tailwind CSS plugin. |
|
||||
| `package.json` | Project metadata, dependencies, and scripts (`dev`, `build`, `start`, `lint`, `format`). |
|
||||
| `components.json` | shadcn/ui configuration. Defines the component install path (`src/shared/components/ui`), style settings, and Tailwind config path. |
|
||||
| `.gitignore` | Ignores `node_modules/`, `.next/`, `.env.local`, `.env`, build output, OS files. |
|
||||
| `.eslintrc.json` | ESLint rules. Extends Next.js defaults with TypeScript-specific rules. |
|
||||
| `.prettierrc` | Prettier formatting configuration. Single quotes, trailing commas, 2-space indent, 100 character line width. |
|
||||
| `CONTEXT.md` | Project context and requirements document. The original specification that drives all architecture decisions. |
|
||||
|
||||
---
|
||||
|
||||
## Directory Conventions
|
||||
|
||||
### 1. Separation of Routing and Logic
|
||||
|
||||
The `src/app/` directory contains **only routing and layout files**. All business logic, domain components, hooks, and services live in `src/modules/` or `src/shared/`. Route page files are thin: they import a module's components, check feature flags, and render.
|
||||
|
||||
```typescript
|
||||
// src/app/(modules)/registratura/page.tsx — correct (thin wrapper)
|
||||
import { EntryTable } from '@/modules/registratura';
|
||||
import { FeatureGate } from '@/core/feature-flags';
|
||||
|
||||
export default function RegistraturaPage() {
|
||||
return (
|
||||
<FeatureGate flag="module.registratura">
|
||||
<EntryTable />
|
||||
</FeatureGate>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Module Self-Containment
|
||||
|
||||
Each module directory under `src/modules/` must be fully self-contained. If you delete a module's directory and remove its entry from `src/config/modules.ts`, the application must still build and run without errors. This means:
|
||||
|
||||
- No other module imports from this module's internals
|
||||
- No shared component depends on a specific module
|
||||
- The module's route page is guarded by a feature flag
|
||||
|
||||
### 3. Core Systems Are Not Modules
|
||||
|
||||
The `src/core/` directory contains platform infrastructure, not business modules. Core systems are always available regardless of feature flags. They do not have route pages. They provide services consumed by modules and the shell.
|
||||
|
||||
### 4. Shared Components Require Multi-Module Use
|
||||
|
||||
A component moves to `src/shared/components/common/` only when it is needed by two or more modules. Components used by a single module stay in that module's `components/` directory, even if they seem generic. Premature extraction creates coupling.
|
||||
|
||||
### 5. Config Files Are Declarative
|
||||
|
||||
Files in `src/config/` are pure data and simple builder functions. They do not contain business logic, side effects, or React components. They export configuration objects and arrays consumed by core systems.
|
||||
|
||||
---
|
||||
|
||||
## File Naming Rules
|
||||
|
||||
### General Rules
|
||||
|
||||
| Rule | Convention | Example |
|
||||
|------|-----------|---------|
|
||||
| File names | `kebab-case` | `entry-form.tsx`, `use-entries.ts` |
|
||||
| Directory names | `kebab-case` | `module-registry/`, `email-signature/` |
|
||||
| React components | `PascalCase` export, `kebab-case` filename | `entry-form.tsx` exports `EntryForm` |
|
||||
| Hooks | `use-` prefix, `camelCase` export | `use-entries.ts` exports `useEntries` |
|
||||
| Services | `-service` suffix | `entry-service.ts`, `tag-service.ts` |
|
||||
| Type files | `types.ts` (one per module/core system) | `src/modules/registratura/types.ts` |
|
||||
| Config files | `config.ts` (module) or descriptive name (global) | `config.ts`, `flags.ts` |
|
||||
| Barrel exports | `index.ts` | Every module and core system has one |
|
||||
| Test files | `.test.ts` / `.test.tsx` co-located | `entry-service.test.ts` |
|
||||
|
||||
### Specific Patterns
|
||||
|
||||
**React component files:**
|
||||
```
|
||||
kebab-case.tsx → exports PascalCase component
|
||||
entry-form.tsx → export function EntryForm() {}
|
||||
confirm-dialog.tsx → export function ConfirmDialog() {}
|
||||
```
|
||||
|
||||
**Hook files:**
|
||||
```
|
||||
use-kebab-case.ts → exports camelCase hook
|
||||
use-entries.ts → export function useEntries() {}
|
||||
use-feature-flag.ts → export function useFeatureFlag() {}
|
||||
```
|
||||
|
||||
**Service files:**
|
||||
```
|
||||
kebab-case-service.ts → exports named functions
|
||||
entry-service.ts → export function getEntries() {}
|
||||
tag-service.ts → export function createTag() {}
|
||||
```
|
||||
|
||||
**Provider files (React context):**
|
||||
```
|
||||
kebab-case-provider.tsx → exports PascalCase provider component
|
||||
theme-provider.tsx → export function ThemeProvider() {}
|
||||
storage-provider.tsx → export function StorageProvider() {}
|
||||
```
|
||||
|
||||
**Type files:**
|
||||
```
|
||||
types.ts → exports interfaces and type aliases
|
||||
No default exports in type files
|
||||
One types.ts per module or core system
|
||||
```
|
||||
|
||||
**Config files:**
|
||||
```
|
||||
config.ts → module config (in module directory)
|
||||
modules.ts → global module registry (in src/config/)
|
||||
flags.ts → global feature flags (in src/config/)
|
||||
```
|
||||
|
||||
### Import Aliases
|
||||
|
||||
The `tsconfig.json` defines a path alias:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All imports use the `@/` alias. Never use relative paths that traverse above the current module.
|
||||
|
||||
```typescript
|
||||
// Correct
|
||||
import { StorageService } from '@/core/storage';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { useEntries } from '@/modules/registratura/hooks/use-entries';
|
||||
|
||||
// Incorrect
|
||||
import { StorageService } from '../../../core/storage';
|
||||
import { something } from '@/modules/other-module/internal-thing';
|
||||
```
|
||||
|
||||
### Language in Code
|
||||
|
||||
- All file names: **English**
|
||||
- All variable, function, class, and type names: **English**
|
||||
- All code comments: **English**
|
||||
- All UI-facing strings: **Romanian** (accessed via i18n labels, never hardcoded in components)
|
||||
- All documentation: **English**
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Module — Checklist
|
||||
|
||||
1. Create `src/modules/[module-name]/` with the standard structure (`components/`, `hooks/`, `services/`, `types.ts`, `config.ts`, `index.ts`)
|
||||
2. Define `ModuleConfig` in `config.ts`
|
||||
3. Add the module config import to `src/config/modules.ts`
|
||||
4. Add the feature flag to `src/config/flags.ts`
|
||||
5. Create the route directory `src/app/(modules)/[module-name]/page.tsx`
|
||||
6. Wrap the page content in `<FeatureGate>`
|
||||
7. Add Romanian labels to `src/core/i18n/locales/ro.ts` under the module namespace
|
||||
8. If complex, create a design doc in `docs/modules/[MODULE-NAME].md`
|
||||
572
docs/architecture/FEATURE-FLAGS.md
Normal file
572
docs/architecture/FEATURE-FLAGS.md
Normal file
@@ -0,0 +1,572 @@
|
||||
# Feature Flag System
|
||||
|
||||
> ArchiTools internal architecture reference -- feature flag design, implementation, and usage patterns.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The feature flag system controls which modules and capabilities are available at runtime. It serves three purposes:
|
||||
|
||||
1. **Module gating** -- a module whose flag is disabled is never imported, never bundled into the active page, and never visible in navigation.
|
||||
2. **Incremental rollout** -- experimental features can be enabled for specific users or environments without a separate deployment.
|
||||
3. **Operational control** -- system flags allow disabling capabilities (e.g., AI features) without a code change.
|
||||
|
||||
Flags are defined statically in `src/config/flags.ts`, consumed via a React context provider, and checked with a hook or a gate component. There is no remote flag service today; the system is designed so one can be added without changing consumer code.
|
||||
|
||||
---
|
||||
|
||||
## Flag Definition Model
|
||||
|
||||
```typescript
|
||||
// src/types/flags.ts
|
||||
|
||||
type FlagCategory = 'module' | 'experimental' | 'system';
|
||||
|
||||
interface FeatureFlag {
|
||||
key: string; // unique identifier, matches module's featureFlag field
|
||||
enabled: boolean; // default state
|
||||
label: string; // Romanian display name
|
||||
description: string; // Romanian description of what this flag controls
|
||||
category: FlagCategory; // classification for the admin UI
|
||||
requiredRole?: Role; // minimum role to see this feature (future use)
|
||||
dependencies?: string[]; // other flag keys that must be enabled for this flag to take effect
|
||||
overridable: boolean; // whether this flag can be toggled at runtime via admin panel
|
||||
}
|
||||
```
|
||||
|
||||
### Field Details
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `key` | Convention: `module.[module-id]` for module flags, `exp.[feature]` for experimental, `sys.[capability]` for system. |
|
||||
| `enabled` | The compiled default. This is what ships. Can be overridden per-environment via env vars or at runtime. |
|
||||
| `dependencies` | If flag A depends on flag B, enabling A while B is disabled has no effect -- the system treats A as disabled. |
|
||||
| `overridable` | System-critical flags (e.g., `sys.storage-engine`) should be `false` to prevent accidental runtime changes. |
|
||||
| `requiredRole` | Not enforced today. Reserved for role-based flag filtering when the auth system is implemented. |
|
||||
|
||||
---
|
||||
|
||||
## Default Flags Configuration
|
||||
|
||||
All flags are declared in a single file. This is the source of truth.
|
||||
|
||||
```typescript
|
||||
// src/config/flags.ts
|
||||
|
||||
import type { FeatureFlag } from '@/types/flags';
|
||||
|
||||
export const defaultFlags: FeatureFlag[] = [
|
||||
// ─── Module Flags ──────────────────────────────────────────
|
||||
{
|
||||
key: 'module.devize-generator',
|
||||
enabled: true,
|
||||
label: 'Generator Devize',
|
||||
description: 'Activeaza modulul de generare devize.',
|
||||
category: 'module',
|
||||
overridable: true,
|
||||
},
|
||||
{
|
||||
key: 'module.oferte',
|
||||
enabled: true,
|
||||
label: 'Oferte',
|
||||
description: 'Activeaza modulul de creare si gestionare oferte.',
|
||||
category: 'module',
|
||||
overridable: true,
|
||||
},
|
||||
{
|
||||
key: 'module.pontaj',
|
||||
enabled: true,
|
||||
label: 'Pontaj',
|
||||
description: 'Activeaza modulul de pontaj si evidenta ore.',
|
||||
category: 'module',
|
||||
overridable: true,
|
||||
},
|
||||
|
||||
// ─── Experimental Flags ────────────────────────────────────
|
||||
{
|
||||
key: 'exp.ai-assistant',
|
||||
enabled: false,
|
||||
label: 'Asistent AI',
|
||||
description: 'Activeaza asistentul AI experimental pentru generare de continut.',
|
||||
category: 'experimental',
|
||||
overridable: true,
|
||||
},
|
||||
{
|
||||
key: 'exp.dark-mode',
|
||||
enabled: false,
|
||||
label: 'Mod Intunecat',
|
||||
description: 'Activeaza tema intunecata (in testare).',
|
||||
category: 'experimental',
|
||||
overridable: true,
|
||||
},
|
||||
|
||||
// ─── System Flags ─────────────────────────────────────────
|
||||
{
|
||||
key: 'sys.offline-mode',
|
||||
enabled: true,
|
||||
label: 'Mod Offline',
|
||||
description: 'Permite functionarea aplicatiei fara conexiune la internet.',
|
||||
category: 'system',
|
||||
overridable: false,
|
||||
},
|
||||
{
|
||||
key: 'sys.analytics',
|
||||
enabled: false,
|
||||
label: 'Analitice',
|
||||
description: 'Colectare de date de utilizare anonimizate.',
|
||||
category: 'system',
|
||||
overridable: false,
|
||||
},
|
||||
];
|
||||
|
||||
/** Build a lookup map for O(1) access by key */
|
||||
export const flagDefaults: Map<string, FeatureFlag> = new Map(
|
||||
defaultFlags.map((flag) => [flag.key, flag])
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FeatureFlagProvider
|
||||
|
||||
The provider initializes flag state from defaults, applies environment variable overrides, applies runtime overrides from localStorage, resolves dependencies, and exposes the resolved state via context.
|
||||
|
||||
```typescript
|
||||
// src/providers/FeatureFlagProvider.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
|
||||
import { defaultFlags, flagDefaults } from '@/config/flags';
|
||||
import type { FeatureFlag } from '@/types/flags';
|
||||
|
||||
interface FlagState {
|
||||
/** Check if a flag is enabled (dependency-resolved) */
|
||||
isEnabled: (key: string) => boolean;
|
||||
/** Get the full flag definition */
|
||||
getFlag: (key: string) => FeatureFlag | undefined;
|
||||
/** Set a runtime override (only for overridable flags) */
|
||||
setOverride: (key: string, enabled: boolean) => void;
|
||||
/** Clear a runtime override */
|
||||
clearOverride: (key: string) => void;
|
||||
/** All flags with their resolved states */
|
||||
allFlags: FeatureFlag[];
|
||||
}
|
||||
|
||||
const FeatureFlagContext = createContext<FlagState | null>(null);
|
||||
|
||||
const STORAGE_KEY = 'architools.flag-overrides';
|
||||
|
||||
function loadOverrides(): Record<string, boolean> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveOverrides(overrides: Record<string, boolean>): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
|
||||
}
|
||||
|
||||
function getEnvOverride(key: string): boolean | undefined {
|
||||
// Convention: NEXT_PUBLIC_FLAG_MODULE_DEVIZE_GENERATOR=true
|
||||
// Transform flag key: "module.devize-generator" -> "MODULE_DEVIZE_GENERATOR"
|
||||
const envKey = `NEXT_PUBLIC_FLAG_${key.replace(/[.-]/g, '_').toUpperCase()}`;
|
||||
|
||||
const value = process.env[envKey];
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function FeatureFlagProvider({ children }: { children: React.ReactNode }) {
|
||||
const [overrides, setOverrides] = useState<Record<string, boolean>>(loadOverrides);
|
||||
|
||||
const resolvedFlags = useMemo(() => {
|
||||
// Step 1: Compute effective enabled state for each flag
|
||||
const effective = new Map<string, boolean>();
|
||||
|
||||
for (const flag of defaultFlags) {
|
||||
// Priority: env override > runtime override > compiled default
|
||||
const envOverride = getEnvOverride(flag.key);
|
||||
const runtimeOverride = overrides[flag.key];
|
||||
const baseValue = envOverride ?? runtimeOverride ?? flag.enabled;
|
||||
effective.set(flag.key, baseValue);
|
||||
}
|
||||
|
||||
// Step 2: Resolve dependencies (a flag is only enabled if all its dependencies are enabled)
|
||||
const resolved = new Map<string, boolean>();
|
||||
|
||||
function resolve(key: string, visited: Set<string> = new Set()): boolean {
|
||||
if (resolved.has(key)) return resolved.get(key)!;
|
||||
if (visited.has(key)) return false; // circular dependency -- fail closed
|
||||
visited.add(key);
|
||||
|
||||
const flag = flagDefaults.get(key);
|
||||
if (!flag) return false;
|
||||
|
||||
let enabled = effective.get(key) ?? false;
|
||||
|
||||
if (enabled && flag.dependencies?.length) {
|
||||
enabled = flag.dependencies.every((dep) => resolve(dep, visited));
|
||||
}
|
||||
|
||||
resolved.set(key, enabled);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
for (const flag of defaultFlags) {
|
||||
resolve(flag.key);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}, [overrides]);
|
||||
|
||||
const setOverride = useCallback((key: string, enabled: boolean) => {
|
||||
const flag = flagDefaults.get(key);
|
||||
if (!flag?.overridable) {
|
||||
console.warn(`[FeatureFlags] Flag "${key}" is not overridable.`);
|
||||
return;
|
||||
}
|
||||
setOverrides((prev) => {
|
||||
const next = { ...prev, [key]: enabled };
|
||||
saveOverrides(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearOverride = useCallback((key: string) => {
|
||||
setOverrides((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
saveOverrides(rest);
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo<FlagState>(() => ({
|
||||
isEnabled: (key: string) => resolvedFlags.get(key) ?? false,
|
||||
getFlag: (key: string) => flagDefaults.get(key),
|
||||
setOverride,
|
||||
clearOverride,
|
||||
allFlags: defaultFlags.map((f) => ({
|
||||
...f,
|
||||
enabled: resolvedFlags.get(f.key) ?? false,
|
||||
})),
|
||||
}), [resolvedFlags, setOverride, clearOverride]);
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFeatureFlagContext(): FlagState {
|
||||
const context = useContext(FeatureFlagContext);
|
||||
if (!context) {
|
||||
throw new Error('useFeatureFlagContext must be used within a FeatureFlagProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Placement
|
||||
|
||||
The provider wraps the entire application layout:
|
||||
|
||||
```typescript
|
||||
// src/app/layout.tsx
|
||||
|
||||
import { FeatureFlagProvider } from '@/providers/FeatureFlagProvider';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ro">
|
||||
<body>
|
||||
<FeatureFlagProvider>
|
||||
{children}
|
||||
</FeatureFlagProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `useFeatureFlag` Hook
|
||||
|
||||
The primary consumer API. Returns a boolean.
|
||||
|
||||
```typescript
|
||||
// src/hooks/useFeatureFlag.ts
|
||||
|
||||
'use client';
|
||||
|
||||
import { useFeatureFlagContext } from '@/providers/FeatureFlagProvider';
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled.
|
||||
* Returns false for unknown keys (fail closed).
|
||||
*/
|
||||
export function useFeatureFlag(key: string): boolean {
|
||||
const { isEnabled } = useFeatureFlagContext();
|
||||
return isEnabled(key);
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
function SomeComponent() {
|
||||
const aiEnabled = useFeatureFlag('exp.ai-assistant');
|
||||
|
||||
return (
|
||||
<div>
|
||||
{aiEnabled && <AiSuggestionPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `FeatureGate` Component
|
||||
|
||||
A declarative alternative to the hook, useful when you want to conditionally render a subtree without introducing a new component.
|
||||
|
||||
```typescript
|
||||
// src/components/FeatureGate.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
|
||||
|
||||
interface FeatureGateProps {
|
||||
flag: string;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders children only if the specified flag is enabled.
|
||||
* Optionally renders a fallback when disabled.
|
||||
*/
|
||||
export function FeatureGate({ flag, children, fallback = null }: FeatureGateProps) {
|
||||
const enabled = useFeatureFlag(flag);
|
||||
return <>{enabled ? children : fallback}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
<FeatureGate flag="exp.dark-mode">
|
||||
<ThemeToggle />
|
||||
</FeatureGate>
|
||||
|
||||
<FeatureGate flag="module.pontaj" fallback={<ModuleDisabledNotice />}>
|
||||
<PontajWidget />
|
||||
</FeatureGate>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flags and Module Loading
|
||||
|
||||
The critical integration point: **a disabled flag means the module's JavaScript is never fetched**.
|
||||
|
||||
This works because of the layered architecture:
|
||||
|
||||
1. **Navigation**: The sidebar queries `useFeatureFlag(module.featureFlag)` for each registered module. Disabled modules are excluded from the nav -- the user has no link to click.
|
||||
|
||||
2. **Route guard**: The module's route page checks the flag before rendering the `ModuleLoader`. If the flag is disabled, it renders a 404 or redirect.
|
||||
|
||||
```typescript
|
||||
// src/app/(modules)/devize/page.tsx
|
||||
|
||||
import { ModuleLoader } from '@/lib/module-loader';
|
||||
import { moduleRegistry } from '@/config/modules';
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function DevizePage() {
|
||||
const config = moduleRegistry.get('devize-generator')!;
|
||||
const enabled = useFeatureFlag(config.featureFlag);
|
||||
|
||||
if (!enabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <ModuleLoader config={config} />;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Lazy import**: `React.lazy(() => import(...))` only executes the import when the component renders. Since the route guard prevents rendering when the flag is off, the dynamic `import()` never fires, and the browser never requests the chunk.
|
||||
|
||||
**Result**: Disabling a module flag removes it from the bundle that the user downloads. There is zero performance cost for disabled modules.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Override Mechanism
|
||||
|
||||
Overridable flags can be toggled at runtime through the admin panel or the browser console. Overrides are stored in `localStorage` under the key `architools.flag-overrides`.
|
||||
|
||||
### Admin Panel
|
||||
|
||||
The admin panel (planned) will render all overridable flags as toggles, grouped by category. It uses `setOverride` and `clearOverride` from the context.
|
||||
|
||||
### Console API (Development)
|
||||
|
||||
For development and debugging, flags can be manipulated directly:
|
||||
|
||||
```javascript
|
||||
// In the browser console:
|
||||
|
||||
// Enable a flag
|
||||
localStorage.setItem('architools.flag-overrides',
|
||||
JSON.stringify({ ...JSON.parse(localStorage.getItem('architools.flag-overrides') || '{}'), 'exp.ai-assistant': true })
|
||||
);
|
||||
// Then reload the page.
|
||||
```
|
||||
|
||||
A convenience utility can be exposed on `window` in development builds:
|
||||
|
||||
```typescript
|
||||
// src/lib/dev-tools.ts
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
(window as any).__flags = {
|
||||
enable: (key: string) => { /* calls setOverride */ },
|
||||
disable: (key: string) => { /* calls setOverride */ },
|
||||
reset: (key: string) => { /* calls clearOverride */ },
|
||||
list: () => { /* logs all flags with their resolved states */ },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flag Categories
|
||||
|
||||
### Module Flags (`module.*`)
|
||||
|
||||
One flag per module. Controls whether the module is loaded and visible. Key must match the `featureFlag` field in the module's `ModuleConfig`.
|
||||
|
||||
Examples: `module.devize-generator`, `module.oferte`, `module.pontaj`.
|
||||
|
||||
### Experimental Flags (`exp.*`)
|
||||
|
||||
Control features that are under active development or testing. These may cut across modules or affect platform-level behavior. Expected to eventually become permanent (either promoted to always-on or removed).
|
||||
|
||||
Examples: `exp.ai-assistant`, `exp.dark-mode`.
|
||||
|
||||
### System Flags (`sys.*`)
|
||||
|
||||
Control infrastructure-level capabilities. Typically not overridable. Changing these may require understanding of downstream effects.
|
||||
|
||||
Examples: `sys.offline-mode`, `sys.analytics`.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variable Overrides
|
||||
|
||||
For deployment-time control, flags can be overridden via environment variables. This is useful for:
|
||||
|
||||
- Enabling experimental features in staging but not production.
|
||||
- Disabling a broken module without a code change.
|
||||
- Per-environment flag profiles in Docker Compose.
|
||||
|
||||
### Convention
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_FLAG_<NORMALIZED_KEY>=true|false
|
||||
```
|
||||
|
||||
The key is normalized by replacing `.` and `-` with `_` and uppercasing:
|
||||
|
||||
| Flag Key | Environment Variable |
|
||||
|---|---|
|
||||
| `module.devize-generator` | `NEXT_PUBLIC_FLAG_MODULE_DEVIZE_GENERATOR` |
|
||||
| `exp.ai-assistant` | `NEXT_PUBLIC_FLAG_EXP_AI_ASSISTANT` |
|
||||
| `sys.offline-mode` | `NEXT_PUBLIC_FLAG_SYS_OFFLINE_MODE` |
|
||||
|
||||
### Priority Order
|
||||
|
||||
```
|
||||
Environment variable > Runtime override (localStorage) > Compiled default
|
||||
```
|
||||
|
||||
Environment variables take the highest priority because they represent a deployment decision that should not be overridden by stale localStorage state.
|
||||
|
||||
### Docker Compose Example
|
||||
|
||||
```yaml
|
||||
# docker-compose.staging.yml
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
- NEXT_PUBLIC_FLAG_EXP_AI_ASSISTANT=true
|
||||
- NEXT_PUBLIC_FLAG_SYS_ANALYTICS=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### 1. Direct Module Import (bypassing flags)
|
||||
|
||||
```typescript
|
||||
// WRONG: This imports the module unconditionally -- it will be in the bundle
|
||||
// regardless of the flag state.
|
||||
import { DevizeGenerator } from '@/modules/devize-generator';
|
||||
```
|
||||
|
||||
Always go through the flag-gated `ModuleLoader` or use `FeatureGate`/`useFeatureFlag`.
|
||||
|
||||
### 2. Checking Flags Outside React
|
||||
|
||||
```typescript
|
||||
// WRONG: This reads the default, not the resolved state with overrides.
|
||||
import { flagDefaults } from '@/config/flags';
|
||||
if (flagDefaults.get('module.pontaj')?.enabled) { ... }
|
||||
```
|
||||
|
||||
Always use `useFeatureFlag()` inside a component or `useFeatureFlagContext()` to get the resolved state.
|
||||
|
||||
### 3. Hardcoding Flag Values
|
||||
|
||||
```typescript
|
||||
// WRONG: This defeats the purpose of having flags.
|
||||
const isAiEnabled = process.env.NODE_ENV === 'production' ? false : true;
|
||||
```
|
||||
|
||||
Use the flag system. If you need environment-specific behavior, use the env var override mechanism.
|
||||
|
||||
### 4. Flag Key Mismatch
|
||||
|
||||
The flag `key` in `flags.ts` must exactly match the `featureFlag` in the module's `config.ts`. A mismatch means the module can never be gated. The registry validation (`validateRegistry`) does not currently check this -- it is enforced by code review and the module development checklist.
|
||||
|
||||
### 5. Non-Overridable Flags in the Admin Panel
|
||||
|
||||
Do not expose `overridable: false` flags in the admin toggle UI. The provider already guards against this, but the UI should also filter them out.
|
||||
|
||||
---
|
||||
|
||||
## Future: Remote Flag Service
|
||||
|
||||
The current system is local-first by design. When a remote flag service is needed (e.g., for per-user rollout, A/B testing, or centralized control), the integration point is the `FeatureFlagProvider`:
|
||||
|
||||
1. On mount, the provider fetches flags from the remote service.
|
||||
2. Remote values are merged into the resolution chain with a priority between env vars and runtime overrides:
|
||||
```
|
||||
Env var > Remote service > Runtime override > Compiled default
|
||||
```
|
||||
3. The provider caches remote flags in localStorage for offline resilience.
|
||||
4. Consumer code (`useFeatureFlag`, `FeatureGate`) requires zero changes.
|
||||
|
||||
The `FeatureFlag` interface and provider API are designed to accommodate this without breaking changes.
|
||||
512
docs/architecture/MODULE-SYSTEM.md
Normal file
512
docs/architecture/MODULE-SYSTEM.md
Normal file
@@ -0,0 +1,512 @@
|
||||
# Module System
|
||||
|
||||
> ArchiTools internal architecture reference -- module registry, loader, and lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ArchiTools is composed of discrete **modules**, each representing a self-contained functional unit (e.g., *Devize*, *Oferte*, *Pontaj*). Modules are registered in a central registry, gated behind feature flags, lazily loaded at runtime, and isolated from one another. This design provides three guarantees:
|
||||
|
||||
1. **Zero bundle cost** for disabled modules -- they are never imported.
|
||||
2. **Runtime safety** -- a crash in one module cannot propagate to another.
|
||||
3. **Clear ownership boundaries** -- each module owns its types, storage, and UI.
|
||||
|
||||
---
|
||||
|
||||
## Module Definition Model
|
||||
|
||||
Every module exposes a `config.ts` at its root that exports a `ModuleConfig` object. This is the module's identity card -- it tells the platform everything it needs to know to register, gate, load, and display the module.
|
||||
|
||||
### `ModuleConfig` Interface
|
||||
|
||||
```typescript
|
||||
// src/core/module-registry/types.ts
|
||||
|
||||
type ModuleCategory = 'operations' | 'generators' | 'management' | 'tools' | 'ai';
|
||||
type Visibility = 'all' | 'admin' | 'internal';
|
||||
|
||||
interface ModuleConfig {
|
||||
id: string; // unique kebab-case identifier, e.g. "devize-generator"
|
||||
name: string; // Romanian display name, e.g. "Generator Devize"
|
||||
description: string; // Romanian description shown in module catalog
|
||||
icon: string; // Lucide icon name, e.g. "file-spreadsheet"
|
||||
route: string; // base route path, e.g. "/devize"
|
||||
category: ModuleCategory; // sidebar grouping
|
||||
featureFlag: string; // corresponding key in the feature flag system
|
||||
visibility: Visibility; // audience restriction
|
||||
version: string; // semver, e.g. "1.0.0"
|
||||
dependencies?: string[]; // other module IDs this depends on
|
||||
storageNamespace: string; // isolated localStorage/IndexedDB prefix
|
||||
navOrder: number; // sidebar sort order within its category
|
||||
tags?: string[]; // capability tags for search/filtering
|
||||
}
|
||||
```
|
||||
|
||||
### Field Contracts
|
||||
|
||||
| Field | Constraint |
|
||||
|---|---|
|
||||
| `id` | Must be unique across all modules. Kebab-case. Used as the key in the registry map. |
|
||||
| `route` | Must start with `/`. Must not collide with another module's route. |
|
||||
| `featureFlag` | Must have a matching entry in `src/config/flags.ts`. |
|
||||
| `storageNamespace` | Must be unique. Convention: `architools.[module-id]`. |
|
||||
| `navOrder` | Lower numbers appear first. Modules in the same category are sorted by this value. |
|
||||
| `dependencies` | If specified, the platform will refuse to load this module unless all listed module IDs are also enabled and loaded. |
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// src/modules/devize-generator/config.ts
|
||||
|
||||
import { ModuleConfig } from '@/core/module-registry/types';
|
||||
|
||||
export const registraturaConfig: ModuleConfig = {
|
||||
id: 'registratura',
|
||||
name: 'Registratură',
|
||||
description: 'Registru de corespondență multi-firmă cu urmărire documente.',
|
||||
icon: 'book-open',
|
||||
route: '/registratura',
|
||||
category: 'operations',
|
||||
featureFlag: 'module.registratura',
|
||||
visibility: 'all',
|
||||
version: '1.0.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'architools.registratura',
|
||||
navOrder: 10,
|
||||
tags: ['registry', 'documents', 'correspondence'],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Directory Structure
|
||||
|
||||
Every module lives under `src/modules/` and follows a strict directory convention:
|
||||
|
||||
```
|
||||
src/modules/[module-name]/
|
||||
components/ # React components specific to this module
|
||||
hooks/ # Custom hooks encapsulating business logic
|
||||
services/ # Data services (CRUD, transformations) using the storage abstraction
|
||||
types.ts # Module-specific TypeScript types and interfaces
|
||||
config.ts # ModuleConfig export (the module's identity)
|
||||
index.ts # Public API barrel export
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- **`components/`** -- Only UI components consumed within this module. If a component is needed by multiple modules, it belongs in `src/shared/components/common/`.
|
||||
- **`hooks/`** -- Business logic hooks. Must not contain UI rendering. Must not directly call `localStorage` or `IndexedDB` -- use the storage abstraction via services.
|
||||
- **`services/`** -- All data access goes through services. Services consume the platform's storage abstraction layer (`src/core/storage/`) and must scope all keys under `storageNamespace`.
|
||||
- **`types.ts`** -- All TypeScript types that are internal to this module. Shared types go in `src/core/*/types.ts`.
|
||||
- **`config.ts`** -- Exports exactly one `ModuleConfig` object. No side effects.
|
||||
- **`index.ts`** -- The barrel export. This is the **only** file that other parts of the system (the registry, the loader) may import from. It must export `config` and the lazy-loadable root component.
|
||||
|
||||
### Barrel Export Convention
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/index.ts
|
||||
|
||||
export { registraturaConfig as config } from './config';
|
||||
export { default as RegistraturaModule } from './components/RegistraturaModule';
|
||||
```
|
||||
|
||||
The root component (`RegistraturaModule`) is what gets lazy-loaded and rendered at the module's route.
|
||||
|
||||
---
|
||||
|
||||
## Module Registry
|
||||
|
||||
The central registry lives at `src/config/modules.ts`. It imports every module's config and builds a lookup map.
|
||||
|
||||
```typescript
|
||||
// src/config/modules.ts
|
||||
|
||||
import { registraturaConfig } from '@/modules/registratura/config';
|
||||
import { emailSignatureConfig } from '@/modules/email-signature/config';
|
||||
import { wordXmlConfig } from '@/modules/word-xml/config';
|
||||
import { promptGeneratorConfig } from '@/modules/prompt-generator/config';
|
||||
// ... all module configs
|
||||
|
||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
||||
|
||||
const moduleConfigs: ModuleConfig[] = [
|
||||
registraturaConfig,
|
||||
emailSignatureConfig,
|
||||
wordXmlConfig,
|
||||
promptGeneratorConfig,
|
||||
// ... add new modules here
|
||||
];
|
||||
|
||||
/** Map of module ID -> ModuleConfig for O(1) lookups */
|
||||
export const moduleRegistry: Map<string, ModuleConfig> = new Map(
|
||||
moduleConfigs.map((config) => [config.id, config])
|
||||
);
|
||||
|
||||
/** All registered configs, sorted by category then navOrder */
|
||||
export const allModules: ModuleConfig[] = [...moduleConfigs].sort((a, b) => {
|
||||
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
||||
return a.navOrder - b.navOrder;
|
||||
});
|
||||
|
||||
/** Get configs for a specific category */
|
||||
export function getModulesByCategory(category: ModuleConfig['category']): ModuleConfig[] {
|
||||
return allModules.filter((m) => m.category === category);
|
||||
}
|
||||
|
||||
/** Validate that all module IDs are unique. Called once at startup in dev mode. */
|
||||
export function validateRegistry(): void {
|
||||
const ids = moduleConfigs.map((m) => m.id);
|
||||
const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(`Duplicate module IDs detected: ${duplicates.join(', ')}`);
|
||||
}
|
||||
|
||||
const routes = moduleConfigs.map((m) => m.route);
|
||||
const duplicateRoutes = routes.filter((r, i) => routes.indexOf(r) !== i);
|
||||
if (duplicateRoutes.length > 0) {
|
||||
throw new Error(`Duplicate module routes detected: ${duplicateRoutes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The registry is a static, build-time artifact. It does not perform any dynamic discovery -- every module must be explicitly imported. This is intentional: it guarantees that the dependency graph is fully visible to the bundler for tree-shaking and code-splitting.
|
||||
|
||||
---
|
||||
|
||||
## Module Loader
|
||||
|
||||
The loader is responsible for conditionally importing and rendering a module based on its feature flag status.
|
||||
|
||||
### Lazy Loading Strategy
|
||||
|
||||
Each module's root component is wrapped in `React.lazy()`. The import only fires when the component is actually rendered, which only happens when the feature flag is enabled.
|
||||
|
||||
```typescript
|
||||
// src/core/module-registry/loader.ts
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
||||
import { ModuleErrorBoundary } from '@/shared/components/common/module-error-boundary';
|
||||
import { ModuleSkeleton } from '@/shared/components/common/module-skeleton';
|
||||
|
||||
/** Registry of lazy component factories, keyed by module ID */
|
||||
const lazyComponents: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
|
||||
'registratura': React.lazy(() => import('@/modules/registratura')),
|
||||
'email-signature': React.lazy(() => import('@/modules/email-signature')),
|
||||
'word-xml': React.lazy(() => import('@/modules/word-xml')),
|
||||
'prompt-generator': React.lazy(() => import('@/modules/prompt-generator')),
|
||||
// ... add new modules here
|
||||
};
|
||||
|
||||
interface ModuleLoaderProps {
|
||||
config: ModuleConfig;
|
||||
}
|
||||
|
||||
export function ModuleLoader({ config }: ModuleLoaderProps) {
|
||||
const LazyComponent = lazyComponents[config.id];
|
||||
|
||||
if (!LazyComponent) {
|
||||
return <ModuleNotFound moduleId={config.id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ModuleErrorBoundary moduleId={config.id} moduleName={config.name}>
|
||||
<Suspense fallback={<ModuleSkeleton moduleName={config.name} />}>
|
||||
<LazyComponent />
|
||||
</Suspense>
|
||||
</ModuleErrorBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Key Mechanics
|
||||
|
||||
1. **`React.lazy()`** accepts a function that returns a dynamic `import()`. The import is only executed the first time the component renders.
|
||||
2. **`Suspense`** shows a skeleton loader while the chunk downloads and parses.
|
||||
3. **`ModuleErrorBoundary`** catches any rendering error within the module and displays a localized error state without crashing the entire application.
|
||||
|
||||
### Why Not Automatic Discovery?
|
||||
|
||||
We intentionally avoid filesystem-based auto-discovery (e.g., `import.meta.glob`) because:
|
||||
|
||||
- It defeats tree-shaking: the bundler must include every discovered module.
|
||||
- It hides the dependency graph from code review.
|
||||
- It makes the build non-deterministic if modules are added/removed from the filesystem.
|
||||
|
||||
Explicit registration is a small amount of boilerplate that pays for itself in predictability.
|
||||
|
||||
---
|
||||
|
||||
## Navigation Auto-Discovery
|
||||
|
||||
The sidebar reads directly from the module registry to build its navigation tree. No separate nav configuration file exists.
|
||||
|
||||
```typescript
|
||||
// src/components/navigation/Sidebar.tsx (simplified)
|
||||
|
||||
import { allModules } from '@/config/modules';
|
||||
import { useFeatureFlag } from '@/core/feature-flags';
|
||||
import { groupBy } from '@/shared/lib/utils';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
operations: 'Operatiuni',
|
||||
generators: 'Generatoare',
|
||||
management: 'Management',
|
||||
tools: 'Instrumente',
|
||||
ai: 'AI & Automatizari',
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
const grouped = groupBy(allModules, (m) => m.category);
|
||||
|
||||
return (
|
||||
<nav>
|
||||
{Object.entries(CATEGORY_LABELS).map(([category, label]) => {
|
||||
const modules = grouped[category] ?? [];
|
||||
const visibleModules = modules.filter((m) => {
|
||||
const flagEnabled = useFeatureFlag(m.featureFlag);
|
||||
return flagEnabled;
|
||||
});
|
||||
|
||||
if (visibleModules.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SidebarGroup key={category} label={label}>
|
||||
{visibleModules.map((m) => (
|
||||
<SidebarItem key={m.id} icon={m.icon} href={m.route}>
|
||||
{m.name}
|
||||
</SidebarItem>
|
||||
))}
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This means:
|
||||
- Adding a module to the registry and enabling its flag automatically adds it to the sidebar.
|
||||
- Disabling a flag automatically removes the module from navigation.
|
||||
- `navOrder` controls position within each category group.
|
||||
- `visibility` can be checked against the current user's role for further filtering.
|
||||
|
||||
---
|
||||
|
||||
## Module Isolation Rules
|
||||
|
||||
Modules must be isolated from one another. This is enforced by convention and code review.
|
||||
|
||||
### What Is Allowed
|
||||
|
||||
| From | To | Allowed? |
|
||||
|---|---|---|
|
||||
| Module A component | Module A hook | Yes |
|
||||
| Module A service | `src/lib/storage/` | Yes |
|
||||
| Module A component | `src/components/shared/` | Yes |
|
||||
| Module A hook | `src/lib/*` (platform utilities) | Yes |
|
||||
| Module A | Module B (any file) | **No** |
|
||||
|
||||
### What Is Not Allowed
|
||||
|
||||
- **Direct imports between modules.** Module A must never `import { something } from '@/modules/module-b/...'`. If two modules need to share data, they do so through a shared service in `src/lib/` or through an event bus.
|
||||
- **Shared mutable state.** No module may write to a global store that another module reads, unless that store is a platform-level shared service (e.g., user context, notification system).
|
||||
- **Cross-module storage access.** Each module's `storageNamespace` is its own territory. Module A must never read or write keys prefixed with Module B's namespace.
|
||||
|
||||
### Enforcement
|
||||
|
||||
- **ESLint rule** (planned): a custom `no-restricted-imports` pattern that forbids `@/modules/*/` imports from within a different module directory.
|
||||
- **Code review**: any PR that introduces a cross-module import must be flagged.
|
||||
|
||||
---
|
||||
|
||||
## Module Lifecycle
|
||||
|
||||
A module goes through the following stages from registration to active use:
|
||||
|
||||
```
|
||||
1. Registration
|
||||
Module config is imported into src/config/modules.ts.
|
||||
Module appears in the registry map.
|
||||
|
||||
2. Feature Flag Check
|
||||
At render time, the navigation system and route handler check
|
||||
the module's featureFlag via useFeatureFlag(). If disabled,
|
||||
the module is invisible and its code is never loaded.
|
||||
|
||||
3. Lazy Load
|
||||
When the user navigates to the module's route (and the flag is
|
||||
enabled), React.lazy() triggers the dynamic import(). The
|
||||
bundler fetches the module's code chunk over the network.
|
||||
|
||||
4. Suspense Boundary
|
||||
While the chunk loads, the Suspense fallback (ModuleSkeleton)
|
||||
is displayed.
|
||||
|
||||
5. Error Boundary Setup
|
||||
The ModuleErrorBoundary wraps the module. If any error occurs
|
||||
during mount or subsequent renders, the boundary catches it.
|
||||
|
||||
6. Mount
|
||||
The module's root component mounts. It may initialize hooks,
|
||||
call services, and read from its storage namespace.
|
||||
|
||||
7. Storage Namespace Init
|
||||
On first use, the module's services initialize their storage
|
||||
keys under the module's storageNamespace prefix. This happens
|
||||
lazily -- no storage is allocated until the module actually
|
||||
writes data.
|
||||
|
||||
8. Active
|
||||
The module is fully interactive. It manages its own state
|
||||
and communicates with the platform only through shared services.
|
||||
|
||||
9. Unmount
|
||||
When the user navigates away, the module unmounts normally via
|
||||
React's unmount lifecycle. Cleanup happens in useEffect
|
||||
return functions as usual.
|
||||
```
|
||||
|
||||
### Diagram
|
||||
|
||||
```
|
||||
Registration ──> Flag Check ──┬──> [disabled] ──> Not loaded, not visible
|
||||
│
|
||||
└──> [enabled] ──> Lazy Load ──> Suspense ──> Mount ──> Active
|
||||
│
|
||||
└──> [error] ──> Error Boundary UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Boundaries
|
||||
|
||||
Every module is wrapped in a `ModuleErrorBoundary`. This is a React error boundary class component that:
|
||||
|
||||
1. Catches JavaScript errors in the module's component tree.
|
||||
2. Logs the error with the module's ID for diagnostics.
|
||||
3. Renders a fallback UI with the module name and a retry button.
|
||||
4. Does **not** crash the shell (sidebar, header, other modules remain functional).
|
||||
|
||||
```typescript
|
||||
// src/components/ModuleErrorBoundary.tsx
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
moduleId: string;
|
||||
moduleName: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ModuleErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error(
|
||||
`[ModuleError] ${this.props.moduleId}:`,
|
||||
error,
|
||||
errorInfo.componentStack
|
||||
);
|
||||
// Future: send to error reporting service
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<h2 className="text-lg font-semibold text-destructive">
|
||||
Eroare in modulul “{this.props.moduleName}”
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
A aparut o eroare neasteptata. Incercati din nou sau contactati echipa tehnica.
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="mt-4 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
|
||||
>
|
||||
Reincearca
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Module
|
||||
|
||||
Step-by-step checklist for adding a module to ArchiTools. For full code examples, see [MODULE-DEVELOPMENT.md](/docs/guides/MODULE-DEVELOPMENT.md).
|
||||
|
||||
### 1. Create the directory structure
|
||||
|
||||
```bash
|
||||
mkdir -p src/modules/[module-name]/{components,hooks,services}
|
||||
touch src/modules/[module-name]/{types.ts,config.ts,index.ts}
|
||||
```
|
||||
|
||||
### 2. Write `config.ts`
|
||||
|
||||
Export a `ModuleConfig` with a unique `id`, `route`, `featureFlag`, and `storageNamespace`.
|
||||
|
||||
### 3. Implement the module
|
||||
|
||||
Build types, services, hooks, and components inside the module directory.
|
||||
|
||||
### 4. Create `index.ts` barrel export
|
||||
|
||||
Export `config` and the default root component.
|
||||
|
||||
### 5. Register in `src/config/modules.ts`
|
||||
|
||||
Import the config and add it to the `moduleConfigs` array.
|
||||
|
||||
### 6. Register the lazy component in `src/lib/module-loader.ts`
|
||||
|
||||
Add a `React.lazy(() => import(...))` entry keyed by the module ID.
|
||||
|
||||
### 7. Add a feature flag in `src/config/flags.ts`
|
||||
|
||||
Create a flag with the key matching the module's `featureFlag` field.
|
||||
|
||||
### 8. Create the route
|
||||
|
||||
Add `src/app/(modules)/[module-name]/page.tsx` that renders the `ModuleLoader`.
|
||||
|
||||
### 9. Test
|
||||
|
||||
- Verify the module appears in the sidebar when the flag is enabled.
|
||||
- Verify the module is completely absent (no network requests for its chunk) when the flag is disabled.
|
||||
- Verify the error boundary catches a thrown error gracefully.
|
||||
- Verify storage operations use the correct namespace prefix.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Module Category Descriptions
|
||||
|
||||
| Category | Key | Intended Use |
|
||||
|---|---|---|
|
||||
| Operațiuni | `operations` | Day-to-day operational tools (registratura, password vault) |
|
||||
| Generatoare | `generators` | Document/artifact generators (email signature, Word XML, Word templates) |
|
||||
| Management | `management` | Registries and asset management (IT inventory, address book, digital signatures) |
|
||||
| Instrumente | `tools` | Utility tools (mini utilities, tag manager) |
|
||||
| AI & Automatizări | `ai` | AI-powered features (prompt generator, AI chat) |
|
||||
430
docs/architecture/SECURITY-AND-ROLES.md
Normal file
430
docs/architecture/SECURITY-AND-ROLES.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Security & Roles
|
||||
|
||||
> ArchiTools internal architecture reference -- security layers, role model, and auth integration plan.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ArchiTools is an internal-only platform deployed on-premise behind Nginx Proxy Manager. It is not exposed to the public internet. The current security posture reflects this: no authentication is required, and all features are available to anyone with network access.
|
||||
|
||||
This document defines the layered security model that is designed today and will be progressively enforced as the platform matures. The architecture is **future-ready** -- every entity, module, and field already carries the metadata needed for access control, even before authentication is turned on.
|
||||
|
||||
---
|
||||
|
||||
## Security Layers
|
||||
|
||||
Security is enforced at five distinct layers, from outermost to innermost:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 1. Network Layer │
|
||||
│ Nginx Proxy Manager, CrowdSec, VPN │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 2. Application Layer │
|
||||
│ Authentik SSO, session management │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 3. Module Layer │
|
||||
│ Feature flags, role-based module gating │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 4. Data Layer │
|
||||
│ Visibility field on all entities │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 5. Field Layer │
|
||||
│ Admin-only fields on entities │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer 1: Network
|
||||
|
||||
| Component | Purpose | Status |
|
||||
|---|---|---|
|
||||
| Nginx Proxy Manager | Reverse proxy, TLS termination, access control lists | Active |
|
||||
| CrowdSec | Intrusion detection, IP reputation, automated banning | Planned |
|
||||
| Internal DNS | ArchiTools resolves only on the office network | Active |
|
||||
| VPN | Remote access for authorized employees only | Active (WireGuard) |
|
||||
|
||||
ArchiTools is **never** exposed on a public IP. All traffic enters through Nginx Proxy Manager on the Docker host. CrowdSec will be added as a sidecar container to provide real-time threat detection and community-sourced IP blocklists.
|
||||
|
||||
### Layer 2: Application (Authentik SSO)
|
||||
|
||||
Not yet active. When enabled, Authentik will serve as the single sign-on provider for all ArchiTools users. See [Auth Integration Plan](#auth-integration-plan) below.
|
||||
|
||||
### Layer 3: Module
|
||||
|
||||
Every module has a `featureFlag` and a `visibility` field in its `ModuleConfig`. Today, feature flags control whether a module is loaded at all. When auth is active, the visibility field will additionally gate access based on the user's role.
|
||||
|
||||
```typescript
|
||||
// Current enforcement (no auth):
|
||||
// featureFlag=true -> module is loaded
|
||||
// featureFlag=false -> module is not loaded, chunk is not fetched
|
||||
|
||||
// Future enforcement (with auth):
|
||||
// featureFlag=true AND role meets visibility -> module is loaded
|
||||
// Otherwise -> module is hidden from navigation and routes return 403
|
||||
```
|
||||
|
||||
### Layer 4: Data
|
||||
|
||||
Every persistent entity in ArchiTools carries a `visibility` field:
|
||||
|
||||
```typescript
|
||||
type Visibility = 'all' | 'internal' | 'admin' | 'guest-safe';
|
||||
```
|
||||
|
||||
| Value | Who can see it |
|
||||
|---|---|
|
||||
| `all` | Any authenticated user (or everyone, in no-auth mode) |
|
||||
| `internal` | Users with role `user` or above (not `guest`) |
|
||||
| `admin` | Users with role `admin` only |
|
||||
| `guest-safe` | Explicitly marked as safe for external/guest viewers |
|
||||
|
||||
This field is stored with the entity data and checked at query time. Services must filter results by visibility before returning them to the UI.
|
||||
|
||||
### Layer 5: Field
|
||||
|
||||
Individual fields on entities can be marked as admin-only. This is enforced at the component level -- admin-only fields are not rendered (not merely hidden with CSS) for non-admin users.
|
||||
|
||||
```typescript
|
||||
interface FieldConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
adminOnly?: boolean; // field is excluded from render for non-admins
|
||||
visibility?: Visibility; // field-level visibility override
|
||||
}
|
||||
```
|
||||
|
||||
When auth is inactive, all fields are rendered (the stub returns admin role).
|
||||
|
||||
---
|
||||
|
||||
## Role Model
|
||||
|
||||
Roles are designed now and embedded into interfaces and type definitions so that the codebase is ready for auth without structural changes.
|
||||
|
||||
```typescript
|
||||
type Role = 'admin' | 'manager' | 'user' | 'viewer' | 'guest';
|
||||
```
|
||||
|
||||
### Role Definitions
|
||||
|
||||
| Role | Module Access | Data Scope | Write Access | Configuration |
|
||||
|---|---|---|---|---|
|
||||
| `admin` | All modules | All data, all companies | Full CRUD | Full (flags, settings, users) |
|
||||
| `manager` | All internal modules | Company-scoped: all data within assigned companies | Full CRUD within scope | Module-level settings |
|
||||
| `user` | Standard modules | Own data + shared data | CRUD on own data | Personal preferences only |
|
||||
| `viewer` | Permitted modules (read-only) | Shared data visible to their role | None | None |
|
||||
| `guest` | Public-facing views only | `guest-safe` entities only | None | None |
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
Roles form a strict hierarchy. A higher role inherits all permissions of lower roles:
|
||||
|
||||
```
|
||||
admin > manager > user > viewer > guest
|
||||
```
|
||||
|
||||
The `hasRole` check uses this hierarchy:
|
||||
|
||||
```typescript
|
||||
const ROLE_HIERARCHY: Record<Role, number> = {
|
||||
guest: 0,
|
||||
viewer: 1,
|
||||
user: 2,
|
||||
manager: 3,
|
||||
admin: 4,
|
||||
};
|
||||
|
||||
function hasRole(requiredRole: Role, actualRole: Role): boolean {
|
||||
return ROLE_HIERARCHY[actualRole] >= ROLE_HIERARCHY[requiredRole];
|
||||
}
|
||||
```
|
||||
|
||||
### Company Scoping
|
||||
|
||||
The three companies (Beletage SRL, Urban Switch SRL, Studii de Teren SRL) are first-class entities. Managers are assigned to one or more companies and can only manage data within their scope.
|
||||
|
||||
```typescript
|
||||
type CompanyId = 'beletage' | 'urban-switch' | 'studii-de-teren';
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
companies: CompanyId[]; // which companies this user belongs to
|
||||
primaryCompany: CompanyId;
|
||||
}
|
||||
```
|
||||
|
||||
Admins are implicitly scoped to all companies. Users and viewers see data for the companies they belong to.
|
||||
|
||||
---
|
||||
|
||||
## Visibility Model
|
||||
|
||||
```typescript
|
||||
type Visibility = 'all' | 'internal' | 'admin' | 'guest-safe';
|
||||
```
|
||||
|
||||
Visibility is applied at three levels:
|
||||
|
||||
| Level | Where it lives | What it controls |
|
||||
|---|---|---|
|
||||
| Module | `ModuleConfig.visibility` | Whether the module appears in navigation and is routable |
|
||||
| Entity | Entity data (e.g., `registryEntry.visibility`) | Whether the entity appears in query results |
|
||||
| Field | `FieldConfig.visibility` or `FieldConfig.adminOnly` | Whether the field is rendered in the UI |
|
||||
|
||||
### Resolution Logic
|
||||
|
||||
```typescript
|
||||
function canView(userRole: Role, visibility: Visibility): boolean {
|
||||
switch (visibility) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'internal':
|
||||
return hasRole('user', userRole);
|
||||
case 'admin':
|
||||
return hasRole('admin', userRole);
|
||||
case 'guest-safe':
|
||||
return true; // visible to everyone including guests
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auth Integration Plan
|
||||
|
||||
### Phase 1: No Auth (Current)
|
||||
|
||||
- All features are available to any user on the network.
|
||||
- The `AuthContext` stub returns a synthetic admin user.
|
||||
- Feature flags are the only access control mechanism.
|
||||
- Visibility fields are stored on entities but not enforced at query time.
|
||||
|
||||
### Phase 2: Authentik SSO + Module Gating
|
||||
|
||||
- Authentik is deployed as a Docker container alongside ArchiTools.
|
||||
- OIDC integration: ArchiTools redirects unauthenticated requests to Authentik.
|
||||
- Authentik manages user accounts, passwords, and MFA.
|
||||
- Role is assigned in Authentik as a custom claim and mapped to the ArchiTools `Role` type.
|
||||
- Module-level gating is enforced: users only see modules permitted by their role + module visibility.
|
||||
- The `AuthContext` reads from the Authentik session instead of the stub.
|
||||
|
||||
### Phase 3: Data-Level Permissions
|
||||
|
||||
- Entity visibility is enforced at query time in services.
|
||||
- Company scoping is active: managers see only their companies' data.
|
||||
- Field-level visibility is enforced: admin-only fields are excluded from non-admin renders.
|
||||
- Audit logging: who accessed what, when.
|
||||
|
||||
### Phase 4: External / Guest Access
|
||||
|
||||
- Guest role is activated for external collaborators.
|
||||
- Scoped views: guests see only `guest-safe` entities, through dedicated guest routes.
|
||||
- Link-based sharing: generate time-limited URLs for specific records.
|
||||
- No direct database access -- guest views are read-only projections.
|
||||
|
||||
---
|
||||
|
||||
## AuthContext Design
|
||||
|
||||
The `AuthContext` is the single source of truth for the current user's identity and permissions throughout the React component tree.
|
||||
|
||||
```typescript
|
||||
// src/lib/auth/types.ts
|
||||
|
||||
interface AuthContext {
|
||||
user: UserProfile | null;
|
||||
role: Role;
|
||||
isAuthenticated: boolean;
|
||||
hasRole(role: Role): boolean;
|
||||
canAccess(moduleId: string): boolean;
|
||||
canView(visibility: Visibility): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Stub Implementation (Phase 1)
|
||||
|
||||
```typescript
|
||||
// src/lib/auth/auth-context.ts
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { AuthContext as AuthContextType } from './types';
|
||||
|
||||
const STUB_USER: UserProfile = {
|
||||
id: 'dev-admin',
|
||||
name: 'Developer',
|
||||
email: 'dev@architools.local',
|
||||
role: 'admin',
|
||||
companies: ['beletage', 'urban-switch', 'studii-de-teren'],
|
||||
primaryCompany: 'beletage',
|
||||
};
|
||||
|
||||
const stubContext: AuthContextType = {
|
||||
user: STUB_USER,
|
||||
role: 'admin',
|
||||
isAuthenticated: true,
|
||||
hasRole: () => true,
|
||||
canAccess: () => true,
|
||||
canView: () => true,
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType>(stubContext);
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
export { AuthContext };
|
||||
```
|
||||
|
||||
In development mode, the stub grants full admin access. When Authentik is integrated (Phase 2), the provider will be swapped to read from the OIDC session, and the stub will only be used in test environments.
|
||||
|
||||
### Usage in Components
|
||||
|
||||
```typescript
|
||||
// Gating a module route
|
||||
function ModulePage({ config }: { config: ModuleConfig }) {
|
||||
const auth = useAuth();
|
||||
|
||||
if (!auth.canAccess(config.id)) {
|
||||
return <AccessDenied />;
|
||||
}
|
||||
|
||||
return <ModuleLoader config={config} />;
|
||||
}
|
||||
|
||||
// Gating a field
|
||||
function EntityDetail({ entity }: { entity: RegistryEntry }) {
|
||||
const auth = useAuth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{entity.title}</p>
|
||||
{auth.hasRole('admin') && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Internal notes: {entity.adminNotes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module-Specific Security Notes
|
||||
|
||||
### Password Vault
|
||||
|
||||
The Password Vault module is a **convenience tool for non-critical credentials only**. It is explicitly **not** a production secrets manager.
|
||||
|
||||
**Constraints:**
|
||||
- Passwords are stored in the browser's localStorage, encrypted with a user-provided passphrase using AES-256-GCM via the Web Crypto API.
|
||||
- The encryption key is derived from the passphrase using PBKDF2 with a per-vault salt.
|
||||
- There is **no server-side key management**, no HSM, and no key escrow.
|
||||
- If the user forgets the passphrase, the vault data is unrecoverable.
|
||||
- This module must display a persistent disclaimer banner:
|
||||
|
||||
> **Atentie:** Acest modul este destinat exclusiv pentru credentiale non-critice (conturi de servicii, parole Wi-Fi, etc.). NU stocati parole bancare, chei SSH private, sau secrete de productie. Folositi un manager de parole dedicat (Bitwarden, 1Password) pentru credentiale critice.
|
||||
|
||||
**What must NOT be stored here:**
|
||||
- Banking credentials
|
||||
- SSH private keys
|
||||
- API keys for production services
|
||||
- Personal identity documents
|
||||
|
||||
### Digital Signatures
|
||||
|
||||
- The module stores **file hashes** (SHA-256), not raw private keys.
|
||||
- Signature verification is hash-based: the document is re-hashed and compared to the stored hash + signer metadata.
|
||||
- No cryptographic signing keys are generated or stored in ArchiTools.
|
||||
- The signature record structure:
|
||||
|
||||
```typescript
|
||||
interface SignatureRecord {
|
||||
id: string;
|
||||
documentHash: string; // SHA-256 hex digest
|
||||
fileName: string;
|
||||
signerName: string;
|
||||
signerCompany: CompanyId;
|
||||
signedAt: string; // ISO 8601 timestamp
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
### Data Export / Import
|
||||
|
||||
- All import operations must sanitize incoming data before writing to storage.
|
||||
- JSON imports: validate against the expected schema using Zod. Reject payloads that do not conform.
|
||||
- CSV imports: escape all string fields, reject fields exceeding length limits.
|
||||
- No `eval()`, no `Function()` constructors, no dynamic code execution on imported data.
|
||||
- Export operations must strip admin-only fields when the exporter's role is below `admin`.
|
||||
|
||||
---
|
||||
|
||||
## Web Security Controls
|
||||
|
||||
### XSS Prevention
|
||||
|
||||
React escapes all interpolated values by default. This is the primary XSS defense.
|
||||
|
||||
**Rules:**
|
||||
- `dangerouslySetInnerHTML` is **prohibited** except in the Email Signature preview and Word XML preview modules. These modules render user-provided HTML in a sandboxed `<iframe>` with `sandbox="allow-same-origin"` (no `allow-scripts`).
|
||||
- User input that flows into HTML attributes (e.g., `href`, `src`) must be validated against an allowlist of protocols (`https:`, `mailto:`, `tel:`).
|
||||
- No inline `<script>` tags are generated anywhere in the application.
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
Next.js App Router provides built-in CSRF protection for Server Actions via origin checking. Since ArchiTools currently uses client-side data storage (localStorage/IndexedDB) rather than a backend API, CSRF is not an active vector. When server-side endpoints are added in future phases, all mutations must use Server Actions or include a CSRF token.
|
||||
|
||||
### Content Security Policy
|
||||
|
||||
The following CSP headers are configured in the Nginx Proxy Manager for the ArchiTools domain:
|
||||
|
||||
```
|
||||
Content-Security-Policy:
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob:;
|
||||
font-src 'self';
|
||||
connect-src 'self';
|
||||
frame-src 'self';
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `unsafe-inline` and `unsafe-eval` in `script-src` are required by Next.js in development. In production, these should be tightened to use nonce-based CSP once the build pipeline supports it.
|
||||
- `object-src 'none'` blocks Flash and other plugin-based content.
|
||||
- `frame-src 'self'` allows the sandboxed iframes used by the Email Signature and Word XML preview modules.
|
||||
|
||||
### Security Headers (Nginx)
|
||||
|
||||
In addition to CSP, the following headers are set at the Nginx level:
|
||||
|
||||
```nginx
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "0" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
```
|
||||
|
||||
`X-XSS-Protection` is set to `0` (disabled) because the modern approach is to rely on CSP rather than the browser's legacy XSS auditor, which can introduce vulnerabilities of its own.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Layer | Current State | Phase 2 | Phase 3 | Phase 4 |
|
||||
|---|---|---|---|---|
|
||||
| Network | Nginx + internal DNS | + CrowdSec | No change | + guest ingress rules |
|
||||
| Application | No auth (stub) | Authentik SSO | No change | + guest OIDC flow |
|
||||
| Module | Feature flags only | + role-based gating | No change | + guest module set |
|
||||
| Data | Visibility stored, not enforced | No change | Enforced at query time | + guest-safe filter |
|
||||
| Field | All fields rendered | No change | Admin-only fields gated | No change |
|
||||
691
docs/architecture/STORAGE-LAYER.md
Normal file
691
docs/architecture/STORAGE-LAYER.md
Normal file
@@ -0,0 +1,691 @@
|
||||
# Storage Layer Architecture
|
||||
|
||||
> Internal reference for the ArchiTools storage abstraction layer.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ArchiTools uses an adapter-based storage abstraction that decouples all module data operations from the underlying persistence mechanism. The default adapter uses `localStorage` for zero-infrastructure demo/development mode. The architecture supports swapping to REST API, MinIO, or database backends without changing module code.
|
||||
|
||||
**Key principle:** No module may import `localStorage`, `fetch` to a storage endpoint, or any persistence API directly. All data access flows through the `StorageService` interface via the `useStorage` hook.
|
||||
|
||||
---
|
||||
|
||||
## StorageService Interface
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/types.ts
|
||||
|
||||
interface StorageService {
|
||||
/** Retrieve a single item by namespace and key. Returns null if not found. */
|
||||
get<T>(namespace: string, key: string): Promise<T | null>;
|
||||
|
||||
/** Persist a value under namespace/key. Overwrites existing value. */
|
||||
set<T>(namespace: string, key: string, value: T): Promise<void>;
|
||||
|
||||
/** Remove a single item. No-op if key does not exist. */
|
||||
delete(namespace: string, key: string): Promise<void>;
|
||||
|
||||
/** List all keys within a namespace. Returns empty array if namespace is empty. */
|
||||
list(namespace: string): Promise<string[]>;
|
||||
|
||||
/** Query all items in a namespace, filtering by predicate. */
|
||||
query<T>(namespace: string, predicate: (item: T) => boolean): Promise<T[]>;
|
||||
|
||||
/** Remove all data within a namespace. */
|
||||
clear(namespace: string): Promise<void>;
|
||||
|
||||
/** Export entire namespace as a key-value record. Used for migration and backup. */
|
||||
export(namespace: string): Promise<Record<string, unknown>>;
|
||||
|
||||
/** Import a key-value record into a namespace. Merges with existing data by default. */
|
||||
import(namespace: string, data: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
All methods are async. Even `LocalStorageAdapter` returns promises to maintain interface compatibility and allow drop-in replacement with async backends.
|
||||
|
||||
---
|
||||
|
||||
## Adapter Pattern
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Module code
|
||||
↓
|
||||
useStorage(namespace) hook
|
||||
↓
|
||||
StorageProvider context
|
||||
↓
|
||||
StorageService interface
|
||||
↓
|
||||
┌─────────────────────────────────┐
|
||||
│ LocalStorageAdapter (default) │
|
||||
│ ApiStorageAdapter (future) │
|
||||
│ MinioAdapter (future) │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Adapter Implementations
|
||||
|
||||
#### `LocalStorageAdapter`
|
||||
|
||||
**File:** `src/lib/storage/adapters/local-storage.ts`
|
||||
|
||||
- Default adapter, used in demo mode and local development.
|
||||
- Client-only. Must not be invoked during SSR; all calls should be guarded or deferred to `useEffect`.
|
||||
- Storage key format: `architools::{namespace}::{key}`
|
||||
- Values are JSON-serialized via `JSON.stringify` / `JSON.parse`.
|
||||
- Enforces per-namespace size quota (default: 2 MB per namespace, configurable).
|
||||
- Total localStorage budget: 5 MB (browser limit). The adapter tracks usage and throws `StorageQuotaExceededError` when limits are hit.
|
||||
|
||||
```typescript
|
||||
// Key format
|
||||
function storageKey(namespace: string, key: string): string {
|
||||
return `architools::${namespace}::${key}`;
|
||||
}
|
||||
```
|
||||
|
||||
**Size tracking:**
|
||||
|
||||
```typescript
|
||||
// Approximate size calculation for quota enforcement
|
||||
function byteSize(value: string): number {
|
||||
return new Blob([value]).size;
|
||||
}
|
||||
```
|
||||
|
||||
#### `ApiStorageAdapter` (Future)
|
||||
|
||||
**File:** `src/lib/storage/adapters/api-storage.ts`
|
||||
|
||||
- Communicates with a REST API backend (`STORAGE_API_URL`).
|
||||
- Endpoint mapping:
|
||||
- `GET /storage/{namespace}/{key}` — get
|
||||
- `PUT /storage/{namespace}/{key}` — set
|
||||
- `DELETE /storage/{namespace}/{key}` — delete
|
||||
- `GET /storage/{namespace}` — list
|
||||
- `POST /storage/{namespace}/query` — query (sends predicate as filter object, not function)
|
||||
- `DELETE /storage/{namespace}` — clear
|
||||
- `GET /storage/{namespace}/export` — export
|
||||
- `POST /storage/{namespace}/import` — import
|
||||
- Authentication: Bearer token from Authentik session (when auth is enabled).
|
||||
- Query method: The API adapter cannot serialize JavaScript predicates. Instead, `query` fetches all items and filters client-side, or accepts a structured filter object for server-side filtering.
|
||||
|
||||
#### `MinioAdapter` (Future)
|
||||
|
||||
**File:** `src/lib/storage/adapters/minio-storage.ts`
|
||||
|
||||
- For file/object storage (signatures, stamps, templates, uploaded documents).
|
||||
- Not a general key-value store; used alongside `ApiStorageAdapter` for binary assets.
|
||||
- Object path format: `{bucket}/{namespace}/{key}`
|
||||
- Presigned URLs for browser uploads/downloads.
|
||||
- Server-side only (access keys must not be exposed to client).
|
||||
|
||||
---
|
||||
|
||||
## Namespace Isolation
|
||||
|
||||
Every module operates within its own namespace. Namespaces are string identifiers that partition data to prevent collisions.
|
||||
|
||||
### Assigned Namespaces
|
||||
|
||||
| Module | Namespace |
|
||||
|---------------------|-----------------------|
|
||||
| Dashboard | `dashboard` |
|
||||
| Registratura | `registratura` |
|
||||
| Email Signature | `email-signature` |
|
||||
| Word XML Generators | `word-xml` |
|
||||
| Digital Signatures | `digital-signatures` |
|
||||
| Password Vault | `password-vault` |
|
||||
| IT Inventory | `it-inventory` |
|
||||
| Address Book | `address-book` |
|
||||
| Prompt Generator | `prompt-generator` |
|
||||
| Word Templates | `word-templates` |
|
||||
| Tag Manager | `tag-manager` |
|
||||
| Mini Utilities | `mini-utilities` |
|
||||
| AI Chat | `ai-chat` |
|
||||
| System/Settings | `system` |
|
||||
|
||||
### Namespace Rules
|
||||
|
||||
1. Namespaces are lowercase kebab-case strings.
|
||||
2. A module must never read or write to another module's namespace directly.
|
||||
3. Cross-module data access happens through shared services (e.g., the tag service reads from `tag-manager` namespace on behalf of any module).
|
||||
4. The `system` namespace is reserved for platform-level configuration (theme preference, sidebar state, feature flag overrides).
|
||||
|
||||
---
|
||||
|
||||
## StorageProvider and useStorage Hook
|
||||
|
||||
### StorageProvider
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/provider.tsx
|
||||
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
|
||||
interface StorageContextValue {
|
||||
service: StorageService;
|
||||
}
|
||||
|
||||
const StorageContext = createContext<StorageContextValue | null>(null);
|
||||
|
||||
interface StorageProviderProps {
|
||||
adapter?: StorageService;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StorageProvider({ adapter, children }: StorageProviderProps) {
|
||||
const service = useMemo(() => {
|
||||
return adapter ?? createDefaultAdapter();
|
||||
}, [adapter]);
|
||||
|
||||
return (
|
||||
<StorageContext.Provider value={{ service }}>
|
||||
{children}
|
||||
</StorageContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `StorageProvider` wraps the application in `src/app/layout.tsx`. Adapter selection is determined at startup based on the `NEXT_PUBLIC_STORAGE_ADAPTER` environment variable.
|
||||
|
||||
### Adapter Factory
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/factory.ts
|
||||
|
||||
export function createDefaultAdapter(): StorageService {
|
||||
const adapterType = process.env.NEXT_PUBLIC_STORAGE_ADAPTER ?? 'localStorage';
|
||||
|
||||
switch (adapterType) {
|
||||
case 'localStorage':
|
||||
return new LocalStorageAdapter();
|
||||
case 'api':
|
||||
return new ApiStorageAdapter(process.env.STORAGE_API_URL!);
|
||||
default:
|
||||
throw new Error(`Unknown storage adapter: ${adapterType}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `useStorage` Hook
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/hooks.ts
|
||||
|
||||
export function useStorage<T = unknown>(namespace: string) {
|
||||
const context = useContext(StorageContext);
|
||||
if (!context) {
|
||||
throw new Error('useStorage must be used within StorageProvider');
|
||||
}
|
||||
const { service } = context;
|
||||
|
||||
return useMemo(() => ({
|
||||
get: (key: string) => service.get<T>(namespace, key),
|
||||
set: (key: string, value: T) => service.set<T>(namespace, key, value),
|
||||
delete: (key: string) => service.delete(namespace, key),
|
||||
list: () => service.list(namespace),
|
||||
query: (predicate: (item: T) => boolean) => service.query<T>(namespace, predicate),
|
||||
clear: () => service.clear(namespace),
|
||||
export: () => service.export(namespace),
|
||||
import: (data: Record<string, unknown>) => service.import(namespace, data),
|
||||
}), [service, namespace]);
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in a module:**
|
||||
|
||||
```typescript
|
||||
function RegistraturaPage() {
|
||||
const storage = useStorage<RegistryEntry>('registratura');
|
||||
|
||||
async function loadEntries() {
|
||||
const keys = await storage.list();
|
||||
// ...
|
||||
}
|
||||
|
||||
async function saveEntry(entry: RegistryEntry) {
|
||||
await storage.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Serialization
|
||||
|
||||
### Rules
|
||||
|
||||
1. All structured data is serialized as JSON via `JSON.stringify`.
|
||||
2. Dates are stored as ISO 8601 strings (`"2025-03-15T10:30:00.000Z"`), never as `Date` objects.
|
||||
3. Binary data (images, files) is not stored in the key-value layer. Use MinIO or base64-encoded strings (for small assets under 100 KB only, e.g., signature images in `digital-signatures`).
|
||||
4. `undefined` values are stripped during serialization. Use `null` for intentional absence.
|
||||
5. Keys must be valid URL path segments: lowercase alphanumeric, hyphens, and underscores only.
|
||||
|
||||
### Validation
|
||||
|
||||
```typescript
|
||||
const KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
|
||||
function validateKey(key: string): void {
|
||||
if (!KEY_PATTERN.test(key)) {
|
||||
throw new StorageKeyError(`Invalid storage key: "${key}"`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### localStorage to API Backend
|
||||
|
||||
The export/import methods on `StorageService` enable namespace-level data migration.
|
||||
|
||||
**Migration flow:**
|
||||
|
||||
```
|
||||
1. User triggers export from admin panel
|
||||
2. For each namespace:
|
||||
a. Call oldAdapter.export(namespace) → JSON record
|
||||
b. Download as backup file (optional)
|
||||
c. Call newAdapter.import(namespace, data)
|
||||
d. Verify item count matches
|
||||
3. Switch NEXT_PUBLIC_STORAGE_ADAPTER to 'api'
|
||||
4. Restart application
|
||||
```
|
||||
|
||||
**Migration script (admin utility):**
|
||||
|
||||
```typescript
|
||||
async function migrateStorage(
|
||||
source: StorageService,
|
||||
target: StorageService,
|
||||
namespaces: string[]
|
||||
): Promise<MigrationReport> {
|
||||
const report: MigrationReport = { namespaces: [], errors: [] };
|
||||
|
||||
for (const ns of namespaces) {
|
||||
try {
|
||||
const data = await source.export(ns);
|
||||
const keyCount = Object.keys(data).length;
|
||||
await target.import(ns, data);
|
||||
const verifyKeys = await target.list(ns);
|
||||
|
||||
report.namespaces.push({
|
||||
namespace: ns,
|
||||
exported: keyCount,
|
||||
imported: verifyKeys.length,
|
||||
success: keyCount === verifyKeys.length,
|
||||
});
|
||||
} catch (error) {
|
||||
report.errors.push({ namespace: ns, error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
```
|
||||
|
||||
### Schema Versioning
|
||||
|
||||
Each namespace should store a `_meta` key containing schema version:
|
||||
|
||||
```typescript
|
||||
interface NamespaceMeta {
|
||||
schemaVersion: number;
|
||||
lastMigration: string; // ISO 8601
|
||||
itemCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
On application startup, the storage layer checks `_meta.schemaVersion` and runs registered migration functions if the version is behind.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_STORAGE_ADAPTER` | `localStorage` | Active adapter: `localStorage`, `api` |
|
||||
| `STORAGE_API_URL` | — | REST API endpoint (for `api` adapter) |
|
||||
| `MINIO_ENDPOINT` | — | MinIO server address |
|
||||
| `MINIO_ACCESS_KEY` | — | MinIO access key (server-side only) |
|
||||
| `MINIO_SECRET_KEY` | — | MinIO secret key (server-side only) |
|
||||
| `MINIO_BUCKET` | `architools` | MinIO bucket name |
|
||||
|
||||
Variables prefixed with `NEXT_PUBLIC_` are available on the client. MinIO credentials are server-side only and must never be exposed to the browser.
|
||||
|
||||
---
|
||||
|
||||
## Size Limits and Quotas
|
||||
|
||||
### localStorage Adapter
|
||||
|
||||
| Limit | Value | Notes |
|
||||
|---|---|---|
|
||||
| Browser total | ~5 MB | Varies by browser. Chrome/Firefox: 5 MB per origin. |
|
||||
| Per-namespace soft limit | 2 MB | Configurable in adapter constructor. |
|
||||
| Per-value max size | 1 MB | Prevents accidental storage of large blobs. |
|
||||
| Key length max | 128 characters | Including namespace prefix. |
|
||||
|
||||
When a quota is exceeded, the adapter throws `StorageQuotaExceededError`. Module code must handle this gracefully — typically by showing a user-facing error and suggesting data export/cleanup.
|
||||
|
||||
### Quota Enforcement
|
||||
|
||||
```typescript
|
||||
class LocalStorageAdapter implements StorageService {
|
||||
private namespaceQuota: number; // bytes, default 2 * 1024 * 1024
|
||||
|
||||
async set<T>(namespace: string, key: string, value: T): Promise<void> {
|
||||
const serialized = JSON.stringify(value);
|
||||
const size = byteSize(serialized);
|
||||
|
||||
if (size > this.maxValueSize) {
|
||||
throw new StorageQuotaExceededError(
|
||||
`Value exceeds max size: ${size} bytes (limit: ${this.maxValueSize})`
|
||||
);
|
||||
}
|
||||
|
||||
const currentUsage = await this.getNamespaceSize(namespace);
|
||||
if (currentUsage + size > this.namespaceQuota) {
|
||||
throw new StorageQuotaExceededError(
|
||||
`Namespace "${namespace}" quota exceeded: ${currentUsage + size} bytes (limit: ${this.namespaceQuota})`
|
||||
);
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKey(namespace, key), serialized);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Types
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/errors.ts
|
||||
|
||||
export class StorageError extends Error {
|
||||
constructor(message: string, public readonly code: string) {
|
||||
super(message);
|
||||
this.name = 'StorageError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageKeyError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message, 'INVALID_KEY');
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageQuotaExceededError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message, 'QUOTA_EXCEEDED');
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageNotFoundError extends StorageError {
|
||||
constructor(namespace: string, key: string) {
|
||||
super(`Key "${key}" not found in namespace "${namespace}"`, 'NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageNetworkError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message, 'NETWORK_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageSerializationError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message, 'SERIALIZATION_ERROR');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
**In adapters:** Adapters catch raw errors (JSON parse failures, network timeouts, `QuotaExceededError` from the browser) and re-throw as typed `StorageError` subclasses.
|
||||
|
||||
**In hooks/modules:** Use try/catch with specific error type checks:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await storage.set(entry.id, entry);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageQuotaExceededError) {
|
||||
toast.error('Spațiul de stocare este plin. Exportați datele vechi.');
|
||||
} else if (error instanceof StorageNetworkError) {
|
||||
toast.error('Eroare de conexiune. Încercați din nou.');
|
||||
} else {
|
||||
toast.error('Eroare la salvare.');
|
||||
console.error('[Storage]', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Patterns
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
Used for fast UI response when data loss risk is low (e.g., updating a tag, toggling a flag).
|
||||
|
||||
```typescript
|
||||
function useOptimisticUpdate<T extends BaseEntity>(namespace: string) {
|
||||
const storage = useStorage<T>(namespace);
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
|
||||
async function update(id: string, patch: Partial<T>) {
|
||||
// 1. Apply optimistically to local state
|
||||
const previous = [...items];
|
||||
setItems(items.map(item =>
|
||||
item.id === id ? { ...item, ...patch, updatedAt: new Date().toISOString() } : item
|
||||
));
|
||||
|
||||
try {
|
||||
// 2. Persist to storage
|
||||
const current = await storage.get(id);
|
||||
if (current) {
|
||||
await storage.set(id, { ...current, ...patch, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
} catch (error) {
|
||||
// 3. Rollback on failure
|
||||
setItems(previous);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { items, setItems, update };
|
||||
}
|
||||
```
|
||||
|
||||
### Pessimistic Updates
|
||||
|
||||
Used when data integrity is critical (e.g., registry entries, password vault). The UI waits for confirmation before updating local state.
|
||||
|
||||
```typescript
|
||||
async function saveEntry(entry: RegistryEntry) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await storage.set(entry.id, entry);
|
||||
// Only update UI after successful persistence
|
||||
setEntries(prev => [...prev, entry]);
|
||||
} catch (error) {
|
||||
// UI remains unchanged; show error
|
||||
handleStorageError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Guidelines
|
||||
|
||||
| Scenario | Pattern | Reason |
|
||||
|---|---|---|
|
||||
| Tag edits, dashboard widget reorder | Optimistic | Low risk, frequent interaction |
|
||||
| Registry entry creation | Pessimistic | Data integrity matters |
|
||||
| Password vault writes | Pessimistic | Security-sensitive |
|
||||
| Bulk import | Pessimistic with progress | Large operation, needs feedback |
|
||||
| Settings/preferences | Optimistic | Trivial to re-apply |
|
||||
|
||||
---
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
### localStorage Adapter
|
||||
|
||||
No caching layer needed. `localStorage` is synchronous and in-memory in the browser. The async wrapper adds negligible overhead.
|
||||
|
||||
### API Adapter (Future)
|
||||
|
||||
The API adapter should implement a write-through cache:
|
||||
|
||||
```typescript
|
||||
class ApiStorageAdapter implements StorageService {
|
||||
private cache: Map<string, { value: unknown; timestamp: number }> = new Map();
|
||||
private cacheTTL = 30_000; // 30 seconds
|
||||
|
||||
async get<T>(namespace: string, key: string): Promise<T | null> {
|
||||
const cacheKey = `${namespace}::${key}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
|
||||
return cached.value as T;
|
||||
}
|
||||
|
||||
const result = await this.fetchFromApi<T>(namespace, key);
|
||||
if (result !== null) {
|
||||
this.cache.set(cacheKey, { value: result, timestamp: Date.now() });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async set<T>(namespace: string, key: string, value: T): Promise<void> {
|
||||
await this.putToApi(namespace, key, value);
|
||||
// Write-through: update cache immediately
|
||||
this.cache.set(`${namespace}::${key}`, { value, timestamp: Date.now() });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cache invalidation happens on `set`, `delete`, and `clear`. The `query` method always bypasses cache and fetches fresh data.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Tab Synchronization
|
||||
|
||||
### Problem
|
||||
|
||||
When using `localStorage`, multiple browser tabs may read stale data if one tab writes and another has already loaded its state.
|
||||
|
||||
### Solution
|
||||
|
||||
The `LocalStorageAdapter` listens to the `storage` event, which fires when another tab modifies `localStorage`:
|
||||
|
||||
```typescript
|
||||
// src/lib/storage/adapters/local-storage.ts
|
||||
|
||||
class LocalStorageAdapter implements StorageService {
|
||||
private listeners: Map<string, Set<(key: string) => void>> = new Map();
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', this.handleStorageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStorageEvent = (event: StorageEvent) => {
|
||||
if (!event.key?.startsWith('architools::')) return;
|
||||
|
||||
const [, namespace, key] = event.key.split('::');
|
||||
const nsListeners = this.listeners.get(namespace);
|
||||
if (nsListeners) {
|
||||
nsListeners.forEach(listener => listener(key));
|
||||
}
|
||||
};
|
||||
|
||||
/** Subscribe to changes in a namespace from other tabs. */
|
||||
onExternalChange(namespace: string, callback: (key: string) => void): () => void {
|
||||
if (!this.listeners.has(namespace)) {
|
||||
this.listeners.set(namespace, new Set());
|
||||
}
|
||||
this.listeners.get(namespace)!.add(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.listeners.get(namespace)?.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('storage', this.handleStorageEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Integration
|
||||
|
||||
```typescript
|
||||
export function useStorageSync(namespace: string, onSync: () => void) {
|
||||
const { service } = useContext(StorageContext)!;
|
||||
|
||||
useEffect(() => {
|
||||
if ('onExternalChange' in service) {
|
||||
const unsubscribe = (service as LocalStorageAdapter).onExternalChange(
|
||||
namespace,
|
||||
() => onSync()
|
||||
);
|
||||
return unsubscribe;
|
||||
}
|
||||
}, [service, namespace, onSync]);
|
||||
}
|
||||
```
|
||||
|
||||
Modules that display lists (registratura, address book) should call `useStorageSync` to re-fetch data when another tab makes changes.
|
||||
|
||||
### Limitations
|
||||
|
||||
- The `storage` event only fires in _other_ tabs, not the tab that made the change.
|
||||
- The API adapter does not support cross-tab sync natively. If needed, it would require WebSocket or polling.
|
||||
- Cross-tab sync is best-effort, not transactional. Two tabs writing the same key simultaneously can cause last-write-wins conflicts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/lib/storage/
|
||||
├── types.ts # StorageService interface, NamespaceMeta
|
||||
├── errors.ts # Error classes
|
||||
├── factory.ts # createDefaultAdapter()
|
||||
├── provider.tsx # StorageProvider context
|
||||
├── hooks.ts # useStorage, useStorageSync
|
||||
├── constants.ts # Namespace list, quota defaults
|
||||
└── adapters/
|
||||
├── local-storage.ts # LocalStorageAdapter
|
||||
├── api-storage.ts # ApiStorageAdapter (stub)
|
||||
└── minio-storage.ts # MinioAdapter (stub)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Adapters are tested with a shared test suite that runs against the `StorageService` interface. Swap the adapter instance to test each implementation identically.
|
||||
- `LocalStorageAdapter` tests use a mock `localStorage` (e.g., `jest-localstorage-mock` or a simple in-memory Map).
|
||||
- Integration tests verify namespace isolation: writing to namespace A must never affect namespace B.
|
||||
- Quota tests verify that `StorageQuotaExceededError` is thrown at the correct thresholds.
|
||||
702
docs/architecture/SYSTEM-ARCHITECTURE.md
Normal file
702
docs/architecture/SYSTEM-ARCHITECTURE.md
Normal file
@@ -0,0 +1,702 @@
|
||||
# ArchiTools — System Architecture
|
||||
|
||||
## 1. Platform Overview
|
||||
|
||||
ArchiTools is a modular internal web dashboard platform built for a group of architecture, urban design, and engineering companies based in Cluj-Napoca, Romania:
|
||||
|
||||
- **Beletage SRL** — architecture office
|
||||
- **Urban Switch SRL** — architecture and urban projects
|
||||
- **Studii de Teren SRL** — engineering, surveying, GIS, technical studies
|
||||
|
||||
The platform centralizes daily operational tools: document registries, generators, templates, inventories, AI-assisted workflows, and technical utilities. It replaces scattered standalone HTML tools and manual processes with a unified, themeable, module-driven dashboard.
|
||||
|
||||
**Key constraints:**
|
||||
|
||||
- Internal-first deployment (external/guest access planned for later phases)
|
||||
- Romanian UI labels; English code and comments
|
||||
- On-premise Docker deployment behind reverse proxy
|
||||
- localStorage as initial persistence layer, abstracted for future database/object store migration
|
||||
- Must function as a **module platform**, not a monolithic application
|
||||
|
||||
---
|
||||
|
||||
## 2. High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ NGINX PROXY MANAGER │
|
||||
│ (TLS termination, routing) │
|
||||
└──────────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ DOCKER CONTAINER (Next.js) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ PRESENTATION LAYER │ │
|
||||
│ │ App Shell │ Sidebar │ Theme │ i18n │ Module Routes │ │
|
||||
│ └──────────────────────────┬────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────┴────────────────────────────────────┐ │
|
||||
│ │ MODULE LAYER │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │Registra- │ │ Email │ │ Word XML │ │ Prompt │ ... │ │
|
||||
│ │ │ tura │ │Signature │ │Generator │ │Generator │ │ │
|
||||
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ └───────┼─────────────┼────────────┼─────────────┼──────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌───────┴─────────────┴────────────┴─────────────┴──────────────┐ │
|
||||
│ │ CORE SERVICES LAYER │ │
|
||||
│ │ │ │
|
||||
│ │ Module Registry │ Feature Flags │ Storage Abstraction │ │
|
||||
│ │ Tagging System │ i18n Engine │ Theme Provider │ │
|
||||
│ │ Auth Stub │ Navigation │ Config │ │
|
||||
│ └──────────────────────────┬────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────┴────────────────────────────────────┐ │
|
||||
│ │ STORAGE LAYER │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ localStorage │ │ MinIO │ │ Database │ │ │
|
||||
│ │ │ (current) │ │ (planned) │ │ (planned) │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Authentik │ │ MinIO │ │ N8N │
|
||||
│ (SSO) │ │ (obj store) │ │ (automation) │
|
||||
└─────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Architecture Principles
|
||||
|
||||
### 3.1 Module Platform, Not Monolith
|
||||
|
||||
The application is a **platform that hosts modules**. The shell (layout, sidebar, theme, navigation) exists to load and present modules. No module is assumed to exist at build time — the system must function with zero modules enabled.
|
||||
|
||||
### 3.2 Module Isolation
|
||||
|
||||
Each module owns its:
|
||||
- Route subtree (`/app/(modules)/[module-name]/`)
|
||||
- Business logic (`/modules/[module-name]/`)
|
||||
- Types, services, hooks, and components
|
||||
- Storage namespace (scoped key prefix)
|
||||
- Configuration entry in the module registry
|
||||
|
||||
Modules must never import from another module's internal directories. Cross-module communication happens exclusively through core services (tagging system, storage abstraction, shared hooks).
|
||||
|
||||
### 3.3 Removability
|
||||
|
||||
Disabling a module via feature flag must not produce build errors, runtime errors, or broken navigation. This is enforced by:
|
||||
- Config-driven navigation (only enabled modules appear)
|
||||
- Dynamic imports for module routes
|
||||
- No direct cross-module imports
|
||||
- Feature flag guards at route and component boundaries
|
||||
|
||||
### 3.4 Storage Independence
|
||||
|
||||
No module or component may call `localStorage`, `sessionStorage`, or any browser storage API directly. All persistence flows through the storage abstraction layer, which resolves to the active adapter at runtime.
|
||||
|
||||
### 3.5 Presentation/Logic Separation
|
||||
|
||||
UI components receive data via props and hooks. Business logic lives in `services/` (pure functions) and `hooks/` (stateful logic). Components do not contain data transformation, validation, or persistence logic.
|
||||
|
||||
---
|
||||
|
||||
## 4. Layer Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION LAYER │
|
||||
│ Next.js App Router pages, layouts, UI components │
|
||||
│ shadcn/ui primitives, Tailwind styling │
|
||||
│ Theme provider, i18n labels │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ MODULE LAYER │
|
||||
│ Module-specific components, hooks, services │
|
||||
│ Module config, types, route pages │
|
||||
│ Isolated per module, lazy-loadable │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ CORE SERVICES LAYER │
|
||||
│ Module registry, feature flags, navigation │
|
||||
│ Storage abstraction, tagging, auth stub │
|
||||
│ i18n engine, theme system, config │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ STORAGE / INTEGRATION LAYER │
|
||||
│ Storage adapters (localStorage, MinIO, DB, API) │
|
||||
│ External service clients │
|
||||
│ Environment configuration │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer Rules
|
||||
|
||||
| Layer | May Import From | Must Not Import From |
|
||||
|-------|----------------|---------------------|
|
||||
| Presentation | Module Layer, Core Services, Shared | — |
|
||||
| Module Layer | Core Services, Shared | Other modules |
|
||||
| Core Services | Shared, Config | Module Layer, Presentation |
|
||||
| Storage/Integration | Config only | Everything else |
|
||||
|
||||
---
|
||||
|
||||
## 5. Runtime Architecture
|
||||
|
||||
### 5.1 Next.js App Router
|
||||
|
||||
The application uses Next.js 15 with the App Router. The routing structure:
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx → Root layout: providers, shell wrapper
|
||||
├── page.tsx → Dashboard home (widget grid)
|
||||
├── globals.css → Tailwind base + custom tokens
|
||||
└── (modules)/ → Route group (no URL segment)
|
||||
├── registratura/
|
||||
│ ├── page.tsx → Module entry page
|
||||
│ └── [id]/
|
||||
│ └── page.tsx → Detail view
|
||||
├── email-signature/
|
||||
│ └── page.tsx
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 5.2 Client-Side Primary, SSR Where Needed
|
||||
|
||||
The platform is primarily a client-side interactive application. Most module pages use `"use client"` directives because they:
|
||||
- Manage complex form state
|
||||
- Interact with browser storage
|
||||
- Require immediate user feedback
|
||||
- Handle drag-and-drop, clipboard, and other browser APIs
|
||||
|
||||
Server-side rendering is used for:
|
||||
- The shell layout (static structure, fast first paint)
|
||||
- Metadata generation
|
||||
- Any future API routes serving data to external consumers
|
||||
|
||||
### 5.3 Provider Stack
|
||||
|
||||
The root layout wraps the application in a provider stack:
|
||||
|
||||
```
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<StorageProvider>
|
||||
<FeatureFlagProvider>
|
||||
<AppShell>
|
||||
{children}
|
||||
</AppShell>
|
||||
</FeatureFlagProvider>
|
||||
</StorageProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
Each provider is independent and can be replaced or extended without affecting others.
|
||||
|
||||
---
|
||||
|
||||
## 6. Module Isolation Model
|
||||
|
||||
### 6.1 Module Structure
|
||||
|
||||
Every module follows a standard directory structure:
|
||||
|
||||
```
|
||||
src/modules/[module-name]/
|
||||
├── components/ # Module-specific React components
|
||||
├── hooks/ # Module-specific React hooks
|
||||
├── services/ # Pure business logic functions
|
||||
├── types.ts # TypeScript interfaces and types
|
||||
├── config.ts # Module metadata for the registry
|
||||
└── index.ts # Public API barrel export
|
||||
```
|
||||
|
||||
### 6.2 Module Registration
|
||||
|
||||
Each module exports a `ModuleConfig` object:
|
||||
|
||||
```typescript
|
||||
interface ModuleConfig {
|
||||
id: string; // Unique identifier (kebab-case)
|
||||
name: string; // Romanian display name
|
||||
description: string; // Romanian description
|
||||
icon: string; // Lucide icon name
|
||||
route: string; // Base route path
|
||||
category: ModuleCategory; // Grouping for navigation
|
||||
enabled: boolean; // Default enabled state
|
||||
featureFlag: string; // Flag key in feature flag system
|
||||
requiredRole?: UserRole; // Minimum role (future use)
|
||||
visibility?: Visibility; // internal | admin | public
|
||||
version: string; // Semver
|
||||
storageNamespace: string; // Storage key prefix
|
||||
}
|
||||
```
|
||||
|
||||
The central module registry (`src/config/modules.ts`) imports all module configs and provides them to the navigation system and feature flag guards.
|
||||
|
||||
### 6.3 Module Lifecycle
|
||||
|
||||
```
|
||||
1. Module config registered in modules.ts
|
||||
2. Feature flag checked at runtime
|
||||
3. If enabled: route is accessible, nav item visible
|
||||
4. Module page loads (dynamic import possible)
|
||||
5. Module initializes its hooks/services
|
||||
6. Module reads/writes through storage abstraction
|
||||
7. If disabled: route returns redirect/404, nav item hidden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Systems Overview
|
||||
|
||||
### 7.1 Module Registry
|
||||
|
||||
**Location:** `src/core/module-registry/`
|
||||
|
||||
Central catalog of all available modules. Provides:
|
||||
- List of all registered modules with metadata
|
||||
- Lookup by ID, route, or category
|
||||
- Filtering by enabled state, visibility, role
|
||||
- Module category grouping for navigation
|
||||
|
||||
The registry is the single source of truth for what modules exist. Navigation, routing guards, and the dashboard widget grid all read from it.
|
||||
|
||||
### 7.2 Feature Flags
|
||||
|
||||
**Location:** `src/core/feature-flags/`
|
||||
|
||||
Controls module activation and experimental feature visibility.
|
||||
|
||||
```typescript
|
||||
interface FeatureFlag {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
scope: 'module' | 'feature' | 'experiment';
|
||||
requiredRole?: UserRole;
|
||||
description: string;
|
||||
}
|
||||
```
|
||||
|
||||
Flag resolution order:
|
||||
1. Environment variable override (`NEXT_PUBLIC_FLAG_*`)
|
||||
2. Runtime config (`src/config/flags.ts`)
|
||||
3. Default from module config
|
||||
|
||||
Flags are checked via the `useFeatureFlag(key)` hook and the `<FeatureGate flag="key">` component wrapper.
|
||||
|
||||
### 7.3 Storage Abstraction
|
||||
|
||||
**Location:** `src/core/storage/`
|
||||
|
||||
All data persistence flows through a `StorageService` interface:
|
||||
|
||||
```typescript
|
||||
interface StorageService {
|
||||
get<T>(namespace: string, key: string): Promise<T | null>;
|
||||
set<T>(namespace: string, key: string, value: T): Promise<void>;
|
||||
delete(namespace: string, key: string): Promise<void>;
|
||||
list(namespace: string): Promise<string[]>;
|
||||
clear(namespace: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Adapters:**
|
||||
|
||||
| Adapter | Status | Use Case |
|
||||
|---------|--------|----------|
|
||||
| `LocalStorageAdapter` | Current | Browser-local persistence, demo/dev mode |
|
||||
| `MinIOAdapter` | Planned | File and object storage (signatures, templates) |
|
||||
| `DatabaseAdapter` | Planned | Structured data (registry entries, inventory) |
|
||||
| `APIAdapter` | Planned | External service delegation |
|
||||
|
||||
The active adapter is resolved at startup from environment configuration. Modules never know which adapter is active.
|
||||
|
||||
**Namespace isolation:** Each module operates within its own namespace (e.g., `registratura:entries`, `password-vault:credentials`). Modules cannot read or write to another module's namespace without explicit cross-module service mediation.
|
||||
|
||||
### 7.4 Tagging System
|
||||
|
||||
**Location:** `src/core/tagging/`
|
||||
|
||||
A cross-module tagging service used by multiple modules to categorize and link entities.
|
||||
|
||||
Tags are structured objects:
|
||||
|
||||
```typescript
|
||||
interface Tag {
|
||||
id: string;
|
||||
label: string; // Romanian display label
|
||||
category: TagCategory; // project | client | domain | custom
|
||||
color?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
The tagging system provides:
|
||||
- Tag CRUD operations (stored via storage abstraction)
|
||||
- Tag selector component (shared UI)
|
||||
- Tag filtering and search
|
||||
- Tag usage tracking across modules
|
||||
|
||||
Modules that use tags: Registratura, Prompt Generator, Word Templates, Digital Signatures, IT Inventory, Address Book.
|
||||
|
||||
### 7.5 Internationalization (i18n)
|
||||
|
||||
**Location:** `src/core/i18n/`
|
||||
|
||||
Current implementation: Romanian-only with structured label access for future multi-language support.
|
||||
|
||||
Labels are organized by module namespace:
|
||||
|
||||
```typescript
|
||||
const labels = {
|
||||
common: {
|
||||
save: 'Salvează',
|
||||
cancel: 'Anulează',
|
||||
delete: 'Șterge',
|
||||
search: 'Caută',
|
||||
// ...
|
||||
},
|
||||
registratura: {
|
||||
title: 'Registratură',
|
||||
newEntry: 'Înregistrare nouă',
|
||||
// ...
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Access pattern: `useLabel('registratura.newEntry')` or `<Label k="common.save" />`.
|
||||
|
||||
The system is designed so that adding a second language requires only adding translation files, not changing component code.
|
||||
|
||||
### 7.6 Theme System
|
||||
|
||||
**Location:** `src/core/theme/`
|
||||
|
||||
Dark/light theme support using CSS custom properties and Tailwind's `dark:` variant.
|
||||
|
||||
Design tokens:
|
||||
- Background and surface colors
|
||||
- Text hierarchy (primary, secondary, muted)
|
||||
- Border and divider colors
|
||||
- Accent colors per company (Beletage, Urban Switch, Studii de Teren)
|
||||
- Semantic colors (success, warning, error, info)
|
||||
|
||||
Theme preference is persisted in storage and respects system preference as default. The theme provider exposes `useTheme()` with `theme`, `setTheme`, and `toggleTheme`.
|
||||
|
||||
Visual style: professional, technical, card-based dashboard. No playful or consumer-oriented aesthetics.
|
||||
|
||||
### 7.7 Auth Stub
|
||||
|
||||
**Location:** `src/core/auth/`
|
||||
|
||||
Current state: no authentication enforced (internal network only).
|
||||
|
||||
The auth module provides a stub interface that modules can code against:
|
||||
|
||||
```typescript
|
||||
interface AuthContext {
|
||||
user: User | null;
|
||||
role: UserRole; // 'admin' | 'user' | 'guest'
|
||||
isAuthenticated: boolean;
|
||||
company: CompanyId | null;
|
||||
permissions: string[];
|
||||
}
|
||||
```
|
||||
|
||||
In the current phase, `AuthContext` returns a default internal user with admin role. When Authentik SSO integration is implemented, the auth module will resolve real user identity from SSO tokens without any module code changes.
|
||||
|
||||
Data model fields (`visibility`, `requiredRole`, `createdBy`) are included from day one so that enabling auth does not require data migration.
|
||||
|
||||
---
|
||||
|
||||
## 8. External Integration Points
|
||||
|
||||
### 8.1 Current Infrastructure
|
||||
|
||||
ArchiTools runs alongside existing services on the internal network:
|
||||
|
||||
| Service | Integration Type | Purpose |
|
||||
|---------|-----------------|---------|
|
||||
| **Authentik** | Future SSO provider | User authentication and role assignment |
|
||||
| **MinIO** | Future storage adapter | Object/file storage for documents, signatures, templates |
|
||||
| **N8N** | Future webhook/API | Workflow automation (document processing, notifications) |
|
||||
| **Gitea** | Development | Source code hosting |
|
||||
| **Stirling PDF** | Dashboard link | PDF manipulation (external tool link) |
|
||||
| **IT-Tools** | Dashboard link | Technical utilities (external tool link) |
|
||||
| **Filebrowser** | Dashboard link | File management (external tool link) |
|
||||
| **Uptime Kuma** | Dashboard widget | Service health status |
|
||||
| **Netdata** | Dashboard widget | Server performance metrics |
|
||||
|
||||
### 8.2 Integration Patterns
|
||||
|
||||
**Dashboard links:** External tools appear as navigation entries or dashboard widgets with `target="_blank"` links. No embedding or API integration needed.
|
||||
|
||||
**Dashboard widgets:** Services like Uptime Kuma and Netdata can expose status endpoints or embed iframes for health/monitoring widgets on the dashboard home.
|
||||
|
||||
**Storage integration (MinIO):** When the MinIO adapter is implemented, modules that manage files (Digital Signatures, Word Templates) will store binary assets in MinIO buckets while keeping metadata in the primary storage.
|
||||
|
||||
**Automation integration (N8N):** Modules can trigger N8N webhooks for automated workflows. Example: Registratura creates a new entry, triggering an N8N workflow that sends a notification or generates a document.
|
||||
|
||||
**SSO integration (Authentik):** The auth stub will be replaced with an Authentik OIDC client. The middleware layer will validate tokens and populate `AuthContext`. No module code changes required.
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Flow Patterns
|
||||
|
||||
### 9.1 Module Data Read
|
||||
|
||||
```
|
||||
User action
|
||||
→ Component calls hook (e.g., useRegistryEntries())
|
||||
→ Hook calls service function (e.g., getEntries())
|
||||
→ Service calls StorageService.get(namespace, key)
|
||||
→ StorageService resolves active adapter
|
||||
→ Adapter reads from storage backend
|
||||
→ Data returns up the chain
|
||||
→ Hook updates state
|
||||
→ Component re-renders
|
||||
```
|
||||
|
||||
### 9.2 Module Data Write
|
||||
|
||||
```
|
||||
User submits form
|
||||
→ Component calls hook mutation (e.g., createEntry(data))
|
||||
→ Hook validates via service (e.g., validateEntry(data))
|
||||
→ Service calls StorageService.set(namespace, key, data)
|
||||
→ Adapter writes to storage backend
|
||||
→ Hook updates local state / invalidates cache
|
||||
→ Component re-renders with new data
|
||||
```
|
||||
|
||||
### 9.3 Cross-Module Data (via Tagging)
|
||||
|
||||
```
|
||||
User tags an entity in Module A
|
||||
→ Module A calls TaggingService.addTag(entityId, tagId)
|
||||
→ Tag association stored in tagging namespace
|
||||
|
||||
User filters by tag in Module B
|
||||
→ Module B calls TaggingService.getEntitiesByTag(tagId)
|
||||
→ Returns entity IDs across modules
|
||||
→ Module B fetches its own entities matching those IDs
|
||||
```
|
||||
|
||||
### 9.4 Feature Flag Check
|
||||
|
||||
```
|
||||
Route or component renders
|
||||
→ <FeatureGate flag="module.registratura">
|
||||
→ useFeatureFlag('module.registratura')
|
||||
→ Checks env override → config → default
|
||||
→ Returns boolean
|
||||
→ Children render or fallback shown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Deployment Architecture
|
||||
|
||||
### 10.1 Container Structure
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Ubuntu Server (on-premise) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Docker (via Portainer) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────────────┐ │ │
|
||||
│ │ │ ArchiTools │ │ Other containers │ │ │
|
||||
│ │ │ (Next.js) │ │ (Authentik, MinIO, │ │ │
|
||||
│ │ │ Port: 3000 │ │ N8N, Gitea, etc.) │ │ │
|
||||
│ │ └──────┬───────┘ └──────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ └─────────┼────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────┴─────────────────────────────────────┐ │
|
||||
│ │ Nginx Proxy Manager │ │
|
||||
│ │ tools.internal.domain → localhost:3000 │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 10.2 Docker Configuration
|
||||
|
||||
**Dockerfile:** Multi-stage build.
|
||||
1. **deps stage** — installs Node.js dependencies
|
||||
2. **build stage** — runs `next build`, produces standalone output
|
||||
3. **runtime stage** — minimal Node.js image, copies standalone build, exposes port 3000
|
||||
|
||||
**docker-compose.yml:** Single service definition for ArchiTools. Environment variables passed from `.env` file. Optional volume mounts for persistent data if needed beyond localStorage.
|
||||
|
||||
### 10.3 Environment Variables
|
||||
|
||||
```
|
||||
# Application
|
||||
NEXT_PUBLIC_APP_URL=https://tools.internal.domain
|
||||
NEXT_PUBLIC_APP_ENV=production
|
||||
|
||||
# Feature flags (override defaults)
|
||||
NEXT_PUBLIC_FLAG_MODULE_REGISTRATURA=true
|
||||
NEXT_PUBLIC_FLAG_MODULE_AI_CHAT=false
|
||||
|
||||
# Future: Storage backend
|
||||
STORAGE_BACKEND=localStorage
|
||||
MINIO_ENDPOINT=minio.internal.domain
|
||||
MINIO_ACCESS_KEY=...
|
||||
MINIO_SECRET_KEY=...
|
||||
|
||||
# Future: Auth
|
||||
AUTHENTIK_ISSUER=https://auth.internal.domain
|
||||
AUTHENTIK_CLIENT_ID=...
|
||||
AUTHENTIK_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
### 10.4 Build and Deploy Flow
|
||||
|
||||
```
|
||||
Developer pushes to Gitea
|
||||
→ (future: CI pipeline builds image)
|
||||
→ Docker image built (manual or Watchtower auto-update)
|
||||
→ Portainer deploys/restarts container
|
||||
→ Nginx Proxy Manager routes traffic
|
||||
→ Users access via internal domain
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Scalability Considerations
|
||||
|
||||
### 11.1 Current Scale
|
||||
|
||||
- **Users:** ~5–20 internal staff across three companies
|
||||
- **Data volume:** Low (hundreds to low thousands of records per module)
|
||||
- **Concurrency:** Minimal (localStorage is per-browser, no shared state conflicts)
|
||||
|
||||
### 11.2 Growth Path
|
||||
|
||||
| Concern | Current | Growth Path |
|
||||
|---------|---------|-------------|
|
||||
| Data persistence | localStorage (per-browser) | Database + MinIO (shared, centralized) |
|
||||
| Authentication | None (network trust) | Authentik SSO with RBAC |
|
||||
| Multi-user data | Isolated per browser | Centralized with user ownership |
|
||||
| File storage | Not supported | MinIO buckets per module |
|
||||
| Search | Client-side filter | Server-side indexed search |
|
||||
| API access | None | Next.js API routes for external consumers |
|
||||
| Automation | Manual | N8N webhooks triggered by module events |
|
||||
|
||||
### 11.3 Module Scaling
|
||||
|
||||
New modules are added by:
|
||||
1. Creating the module directory structure
|
||||
2. Registering the module config
|
||||
3. Adding the feature flag
|
||||
4. Creating the route pages
|
||||
|
||||
No changes to the shell, navigation, or other modules are required. The navigation rebuilds itself from the registry.
|
||||
|
||||
---
|
||||
|
||||
## 12. Security Boundaries
|
||||
|
||||
### 12.1 Current Phase: Internal Network Trust
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Internal Network │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────────────────┐ │
|
||||
│ │ Users │────▶│ ArchiTools │ │
|
||||
│ │ (trusted) │ │ (no auth required) │ │
|
||||
│ └──────────┘ └──────────────────────┘ │
|
||||
│ │
|
||||
│ Security: network-level only │
|
||||
│ Data: browser-local, no shared secrets │
|
||||
│ Risk: low (internal, trusted users) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Current security model:**
|
||||
- Network perimeter security via Crowdsec and firewall rules
|
||||
- No application-level authentication
|
||||
- No sensitive data in localStorage (password vault uses demo-grade encryption)
|
||||
- No external API endpoints exposed
|
||||
- All data stays in the user's browser
|
||||
|
||||
### 12.2 Future Phase: SSO + Role-Based Access
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────┐ ┌────────────┐ ┌──────────────────┐│
|
||||
│ │ Users │───▶│ Authentik │───▶│ ArchiTools ││
|
||||
│ │(internal/ │ │ (SSO) │ │ (auth enforced) ││
|
||||
│ │ external) │ └────────────┘ └──────────────────┘│
|
||||
│ └──────────┘ │
|
||||
│ │
|
||||
│ Security: SSO + RBAC + module permissions │
|
||||
│ Data: centralized DB + MinIO with access control │
|
||||
│ Roles: admin, user, guest │
|
||||
│ Visibility: per-field, per-module, per-company │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Planned security layers:**
|
||||
- Authentik OIDC authentication (SSO)
|
||||
- Role-based module access (admin, user, guest)
|
||||
- Company-scoped data visibility
|
||||
- Per-field visibility metadata (internal, admin, public)
|
||||
- API route protection via middleware token validation
|
||||
- Audit logging for sensitive operations
|
||||
|
||||
**Design-for-security decisions made now:**
|
||||
- All data models include `visibility` and `createdBy` fields
|
||||
- Module configs include `requiredRole` field
|
||||
- Feature flags support role-based activation
|
||||
- Auth context interface defined (stubbed with defaults)
|
||||
- Storage namespace isolation prevents cross-module data leaks
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Technology Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Framework | Next.js 15 (App Router) | Modern React with file-based routing, SSR capability, API routes |
|
||||
| Language | TypeScript | Type safety across modules, better refactorability |
|
||||
| Styling | Tailwind CSS | Utility-first, consistent with shadcn/ui, theme support |
|
||||
| Component library | shadcn/ui | Copy-paste components, full control, professional aesthetic |
|
||||
| Deployment | Docker | Consistent with existing infrastructure (Portainer) |
|
||||
| Initial storage | localStorage | Zero infrastructure, immediate development start |
|
||||
| Storage pattern | Adapter abstraction | Allows migration without module changes |
|
||||
| Auth pattern | Stub with interface | Enables SSO integration without refactoring |
|
||||
|
||||
## Appendix B: Module Catalog
|
||||
|
||||
| Module | ID | Category | Status |
|
||||
|--------|----|----------|--------|
|
||||
| Dashboard | `dashboard` | Core | Planned |
|
||||
| Registratura | `registratura` | Registry | Planned |
|
||||
| Email Signature Generator | `email-signature` | Generators | Planned (legacy exists) |
|
||||
| Word XML Generators | `word-xml` | Generators | Planned (legacy exists) |
|
||||
| Digital Signatures & Stamps | `digital-signatures` | Assets | Planned |
|
||||
| Password Vault | `password-vault` | Security | Planned |
|
||||
| IT Inventory | `it-inventory` | Infrastructure | Planned |
|
||||
| Address Book | `address-book` | Contacts | Planned |
|
||||
| Prompt Generator | `prompt-generator` | AI Tools | Planned |
|
||||
| Word Template Library | `word-templates` | Templates | Planned |
|
||||
| Tag Manager | `tag-manager` | Administration | Planned |
|
||||
| Mini Utilities | `mini-utilities` | Tools | Planned |
|
||||
| AI Chat | `ai-chat` | AI Tools | Planned |
|
||||
571
docs/architecture/TAGGING-SYSTEM.md
Normal file
571
docs/architecture/TAGGING-SYSTEM.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# Tagging System
|
||||
|
||||
> ArchiTools internal architecture reference -- cross-module tagging, tag model, and Tag Manager module.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ArchiTools uses a unified tagging system across all modules. Tags provide categorization, filtering, cross-referencing, and visual identification for entities throughout the platform. The tagging system is inspired by the existing ManicTime tag structure used by the office group and extends it into a structured, typed model.
|
||||
|
||||
The Tag Manager module provides a dedicated CRUD interface for managing tags. All other modules consume tags through a shared `TagService` and a set of reusable UI components.
|
||||
|
||||
---
|
||||
|
||||
## Tag Model
|
||||
|
||||
```typescript
|
||||
// src/types/tags.ts
|
||||
|
||||
interface Tag {
|
||||
id: string; // unique identifier (UUID v4)
|
||||
label: string; // display text (Romanian), e.g. "Verificare proiect"
|
||||
category: TagCategory; // semantic grouping
|
||||
color?: string; // hex color for visual distinction, e.g. "#2563eb"
|
||||
icon?: string; // optional Lucide icon name, e.g. "building"
|
||||
scope: TagScope; // visibility scope
|
||||
moduleId?: string; // if scope is 'module', which module owns this tag
|
||||
companyId?: CompanyId; // if scope is 'company', which company owns this tag
|
||||
parentId?: string; // for hierarchical tags (references another Tag's id)
|
||||
metadata?: Record<string, string>; // extensible key-value pairs
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
}
|
||||
```
|
||||
|
||||
### Tag Categories
|
||||
|
||||
```typescript
|
||||
type TagCategory =
|
||||
| 'project' // project identifiers (e.g., "076 Casa Copernicus")
|
||||
| 'phase' // project phases (CU, DTAC, PT, etc.)
|
||||
| 'activity' // work activities (Releveu, Design interior, etc.)
|
||||
| 'document-type' // document classification (Regulament, Parte desenata, etc.)
|
||||
| 'company' // company association (Beletage, Urban Switch, Studii de Teren)
|
||||
| 'priority' // priority levels (Urgent, Normal, Scazut)
|
||||
| 'status' // status indicators (In lucru, Finalizat, In asteptare)
|
||||
| 'custom'; // user-defined tags that don't fit other categories
|
||||
```
|
||||
|
||||
Each category serves a distinct semantic purpose. Tags within the same category are mutually comparable -- you can filter all `phase` tags to get a list of project phases, or all `activity` tags to see work activities. Categories are fixed in code; adding a new category requires a code change. The `custom` category is the escape hatch for tags that do not fit the predefined categories.
|
||||
|
||||
### Tag Scope
|
||||
|
||||
```typescript
|
||||
type TagScope = 'global' | 'module' | 'company';
|
||||
```
|
||||
|
||||
| Scope | Meaning | Example |
|
||||
|---|---|---|
|
||||
| `global` | Available to all modules across all companies | Phase tags like "DTAC", "PT" |
|
||||
| `module` | Scoped to a single module, not visible elsewhere | "Template favorit" in Prompt Generator |
|
||||
| `company` | Scoped to a single company | "Ofertare" scoped to Beletage |
|
||||
|
||||
When `scope` is `module`, the `moduleId` field must be set. When `scope` is `company`, the `companyId` field must be set. When `scope` is `global`, both are `undefined`.
|
||||
|
||||
---
|
||||
|
||||
## Hierarchical Tags
|
||||
|
||||
Tags support parent-child relationships through the `parentId` field. This enables structured navigation and drill-down filtering.
|
||||
|
||||
### Hierarchy Convention
|
||||
|
||||
```
|
||||
Project (category: 'project')
|
||||
└── Phase (category: 'phase', parentId: project tag id)
|
||||
└── Task (category: 'activity', parentId: phase tag id)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
076 Casa Copernicus (project)
|
||||
├── CU (phase)
|
||||
│ ├── Redactare (activity)
|
||||
│ └── Depunere (activity)
|
||||
├── DTAC (phase)
|
||||
│ ├── Redactare (activity)
|
||||
│ ├── Verificare proiect (activity)
|
||||
│ └── Vizita santier (activity)
|
||||
└── PT (phase)
|
||||
└── Detalii de Executie (activity)
|
||||
```
|
||||
|
||||
### Hierarchy Rules
|
||||
|
||||
- A tag's `parentId` must reference an existing tag.
|
||||
- Deleting a parent tag does not cascade-delete children. Children become root-level tags (their `parentId` is cleared).
|
||||
- Maximum nesting depth: 3 levels. This is enforced in the `TagService` on create/update.
|
||||
- A tag's category is independent of its parent's category. A `phase` tag can be a child of a `project` tag.
|
||||
|
||||
### Querying the Hierarchy
|
||||
|
||||
```typescript
|
||||
// Get all children of a tag
|
||||
function getChildren(tags: Tag[], parentId: string): Tag[] {
|
||||
return tags.filter((tag) => tag.parentId === parentId);
|
||||
}
|
||||
|
||||
// Get the full ancestry chain (bottom-up)
|
||||
function getAncestors(tags: Tag[], tagId: string): Tag[] {
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
const ancestors: Tag[] = [];
|
||||
let current = tagMap.get(tagId);
|
||||
while (current?.parentId) {
|
||||
const parent = tagMap.get(current.parentId);
|
||||
if (parent) ancestors.push(parent);
|
||||
current = parent;
|
||||
}
|
||||
return ancestors.reverse(); // root-first order
|
||||
}
|
||||
|
||||
// Build a tree structure from flat tag list
|
||||
function buildTagTree(tags: Tag[]): TagTreeNode[] {
|
||||
const map = new Map<string, TagTreeNode>();
|
||||
const roots: TagTreeNode[] = [];
|
||||
|
||||
for (const tag of tags) {
|
||||
map.set(tag.id, { tag, children: [] });
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
const node = map.get(tag.id)!;
|
||||
if (tag.parentId && map.has(tag.parentId)) {
|
||||
map.get(tag.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
interface TagTreeNode {
|
||||
tag: Tag;
|
||||
children: TagTreeNode[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TagService
|
||||
|
||||
The `TagService` is the central data access layer for tags. All modules interact with tags exclusively through this service.
|
||||
|
||||
```typescript
|
||||
// src/lib/tags/tag-service.ts
|
||||
|
||||
interface TagService {
|
||||
/** Get all tags, optionally filtered by scope */
|
||||
getAllTags(): Promise<Tag[]>;
|
||||
|
||||
/** Get tags belonging to a specific category */
|
||||
getTagsByCategory(category: TagCategory): Promise<Tag[]>;
|
||||
|
||||
/** Get tags by scope, with optional scope identifier */
|
||||
getTagsByScope(scope: TagScope, scopeId?: string): Promise<Tag[]>;
|
||||
|
||||
/** Get a single tag by ID */
|
||||
getTag(id: string): Promise<Tag | null>;
|
||||
|
||||
/** Get all children of a parent tag */
|
||||
getChildTags(parentId: string): Promise<Tag[]>;
|
||||
|
||||
/** Create a new tag. Validates uniqueness of label within category+scope. */
|
||||
createTag(tag: Omit<Tag, 'id' | 'createdAt' | 'updatedAt'>): Promise<Tag>;
|
||||
|
||||
/** Update an existing tag. Partial updates supported. */
|
||||
updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag>;
|
||||
|
||||
/** Delete a tag. Clears parentId on children. */
|
||||
deleteTag(id: string): Promise<void>;
|
||||
|
||||
/** Full-text search on tag labels */
|
||||
searchTags(query: string): Promise<Tag[]>;
|
||||
|
||||
/** Bulk import tags (used by Tag Manager for import/export) */
|
||||
importTags(tags: Omit<Tag, 'id' | 'createdAt' | 'updatedAt'>[]): Promise<Tag[]>;
|
||||
|
||||
/** Export all tags as a serializable array */
|
||||
exportTags(): Promise<Tag[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Tags are stored under the `architools.tags` namespace in the storage abstraction layer. The storage key layout:
|
||||
|
||||
```
|
||||
architools.tags.all -> Tag[] (master list)
|
||||
architools.tags.index -> Record<TagCategory, string[]> (category -> tag ID index)
|
||||
```
|
||||
|
||||
The index is a denormalized lookup table rebuilt on every write operation. It allows `getTagsByCategory` to resolve without scanning the full tag list.
|
||||
|
||||
### Validation Rules
|
||||
|
||||
| Rule | Enforcement |
|
||||
|---|---|
|
||||
| Label must be non-empty and <= 100 characters | `createTag`, `updateTag` |
|
||||
| Label must be unique within the same `category` + `scope` + `scopeId` | `createTag`, `updateTag` |
|
||||
| `moduleId` required when `scope` is `module` | `createTag`, `updateTag` |
|
||||
| `companyId` required when `scope` is `company` | `createTag`, `updateTag` |
|
||||
| `parentId` must reference an existing tag | `createTag`, `updateTag` |
|
||||
| Max nesting depth: 3 | `createTag`, `updateTag` |
|
||||
| `color` must be a valid 6-digit hex (`#rrggbb`) | `createTag`, `updateTag` |
|
||||
| `icon` must be a valid Lucide icon name | `createTag`, `updateTag` |
|
||||
|
||||
---
|
||||
|
||||
## Tag UI Components
|
||||
|
||||
Four reusable components provide the tag interface across all modules.
|
||||
|
||||
### `TagBadge`
|
||||
|
||||
Displays a single tag as a colored chip.
|
||||
|
||||
```typescript
|
||||
// src/components/shared/tags/TagBadge.tsx
|
||||
|
||||
interface TagBadgeProps {
|
||||
tag: Tag;
|
||||
size?: 'sm' | 'md'; // default: 'sm'
|
||||
removable?: boolean; // shows X button
|
||||
onRemove?: (tagId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
Renders a rounded badge with the tag's `color` as background (with opacity for readability), the `icon` if present, and the `label` as text. The `sm` size is used inline in tables and lists; `md` is used in detail views and forms.
|
||||
|
||||
### `TagSelector`
|
||||
|
||||
Multi-select tag picker with category filtering, search, and tag creation.
|
||||
|
||||
```typescript
|
||||
// src/components/shared/tags/TagSelector.tsx
|
||||
|
||||
interface TagSelectorProps {
|
||||
selectedTagIds: string[];
|
||||
onChange: (tagIds: string[]) => void;
|
||||
categories?: TagCategory[]; // restrict to specific categories
|
||||
scope?: TagScope; // restrict to specific scope
|
||||
scopeId?: string; // module or company ID for scope filtering
|
||||
allowCreate?: boolean; // allow inline tag creation (default: false)
|
||||
placeholder?: string;
|
||||
maxTags?: number; // max selectable tags (default: unlimited)
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Opens a popover with a search input and categorized tag list.
|
||||
- Tags are grouped by category with category headers.
|
||||
- Search filters tags by label (case-insensitive, diacritics-insensitive).
|
||||
- Selected tags appear as `TagBadge` components above the input.
|
||||
- When `allowCreate` is true, typing a label that does not match any existing tag shows a "Creeaza tag: [label]" option.
|
||||
|
||||
### `TagFilter`
|
||||
|
||||
Filter bar for lists and tables. Allows users to select tags and see only entities matching those tags.
|
||||
|
||||
```typescript
|
||||
// src/components/shared/tags/TagFilter.tsx
|
||||
|
||||
interface TagFilterProps {
|
||||
activeTags: string[]; // currently active filter tag IDs
|
||||
onChange: (tagIds: string[]) => void;
|
||||
categories?: TagCategory[]; // which categories to show filter chips for
|
||||
tagCounts?: Record<string, number>; // tag ID -> count of matching entities
|
||||
mode?: 'and' | 'or'; // filter logic (default: 'or')
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Displays as a horizontal chip bar above the list/table.
|
||||
- Each active tag is shown as a `TagBadge` with a remove button.
|
||||
- A "+" button opens the `TagSelector` to add more filter tags.
|
||||
- When `tagCounts` is provided, each tag shows its count in parentheses.
|
||||
- `mode: 'and'` requires entities to match all selected tags; `mode: 'or'` matches any.
|
||||
|
||||
### `TagManager`
|
||||
|
||||
This is the root component of the **Tag Manager module** -- a dedicated interface for full CRUD operations on the global tag catalog.
|
||||
|
||||
**Capabilities:**
|
||||
- Browse all tags in a searchable, filterable table.
|
||||
- Create, edit, and delete tags.
|
||||
- Bulk operations: delete multiple tags, change category, change scope.
|
||||
- Import tags from JSON or CSV.
|
||||
- Export all tags to JSON.
|
||||
- Visual hierarchy browser: tree view of parent-child relationships.
|
||||
- Color picker and icon selector for tag customization.
|
||||
|
||||
The Tag Manager module config:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'tag-manager',
|
||||
name: 'Manager Etichete',
|
||||
description: 'Gestionare centralizata a etichetelor folosite in toate modulele.',
|
||||
icon: 'tags',
|
||||
route: '/tag-manager',
|
||||
category: 'management',
|
||||
featureFlag: 'module.tag-manager',
|
||||
visibility: 'internal',
|
||||
version: '1.0.0',
|
||||
storageNamespace: 'architools.tag-manager',
|
||||
navOrder: 40,
|
||||
tags: ['tags', 'management', 'configuration'],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-Seeded Tags
|
||||
|
||||
The following tags are seeded on first initialization, derived from the existing ManicTime tag structure used by the office group. These provide immediate utility without requiring manual setup.
|
||||
|
||||
### Project Phases (`category: 'phase'`, `scope: 'global'`)
|
||||
|
||||
| Label | Color | Description |
|
||||
|---|---|---|
|
||||
| CU | `#3b82f6` | Certificat de Urbanism |
|
||||
| Schita | `#8b5cf6` | Schita de proiect |
|
||||
| Avize | `#06b6d4` | Obtinere avize |
|
||||
| PUD | `#10b981` | Plan Urbanistic de Detaliu |
|
||||
| AO | `#f59e0b` | Autorizatie de Construire (obtinere) |
|
||||
| PUZ | `#ef4444` | Plan Urbanistic Zonal |
|
||||
| PUG | `#ec4899` | Plan Urbanistic General |
|
||||
| DTAD | `#6366f1` | Documentatie Tehnica pentru Autorizatia de Desfiintare |
|
||||
| DTAC | `#14b8a6` | Documentatie Tehnica pentru Autorizatia de Construire |
|
||||
| PT | `#f97316` | Proiect Tehnic |
|
||||
| Detalii de Executie | `#84cc16` | Detalii de executie |
|
||||
|
||||
### Activities (`category: 'activity'`, `scope: 'global'`)
|
||||
|
||||
| Label | Color |
|
||||
|---|---|
|
||||
| Redactare | `#6366f1` |
|
||||
| Depunere | `#10b981` |
|
||||
| Ridicare | `#f59e0b` |
|
||||
| Verificare proiect | `#ef4444` |
|
||||
| Vizita santier | `#8b5cf6` |
|
||||
| Releveu | `#3b82f6` |
|
||||
| Reclama | `#ec4899` |
|
||||
| Design grafic | `#06b6d4` |
|
||||
| Design interior | `#14b8a6` |
|
||||
| Design exterior | `#84cc16` |
|
||||
|
||||
### Document Types (`category: 'document-type'`, `scope: 'global'`)
|
||||
|
||||
| Label | Color |
|
||||
|---|---|
|
||||
| Regulament | `#6366f1` |
|
||||
| Parte desenata | `#10b981` |
|
||||
| Parte scrisa | `#3b82f6` |
|
||||
|
||||
### Company Tags (`category: 'company'`, `scope: 'global'`)
|
||||
|
||||
| Label | Color | CompanyId |
|
||||
|---|---|---|
|
||||
| Beletage | `#2563eb` | `beletage` |
|
||||
| Urban Switch | `#16a34a` | `urban-switch` |
|
||||
| Studii de Teren | `#dc2626` | `studii-de-teren` |
|
||||
|
||||
### Priority Tags (`category: 'priority'`, `scope: 'global'`)
|
||||
|
||||
| Label | Color |
|
||||
|---|---|
|
||||
| Urgent | `#ef4444` |
|
||||
| Normal | `#3b82f6` |
|
||||
| Scazut | `#6b7280` |
|
||||
|
||||
### Status Tags (`category: 'status'`, `scope: 'global'`)
|
||||
|
||||
| Label | Color |
|
||||
|---|---|
|
||||
| In lucru | `#f59e0b` |
|
||||
| Finalizat | `#10b981` |
|
||||
| In asteptare | `#6b7280` |
|
||||
| Anulat | `#ef4444` |
|
||||
|
||||
### Seeding Implementation
|
||||
|
||||
```typescript
|
||||
// src/lib/tags/seed-tags.ts
|
||||
|
||||
import type { Tag, TagCategory } from '@/types/tags';
|
||||
|
||||
const SEED_TAGS: Omit<Tag, 'id' | 'createdAt' | 'updatedAt'>[] = [
|
||||
{ label: 'CU', category: 'phase', scope: 'global', color: '#3b82f6' },
|
||||
{ label: 'DTAC', category: 'phase', scope: 'global', color: '#14b8a6' },
|
||||
// ... all tags from tables above
|
||||
];
|
||||
|
||||
export async function seedTagsIfEmpty(tagService: TagService): Promise<void> {
|
||||
const existing = await tagService.getAllTags();
|
||||
if (existing.length > 0) return; // only seed into empty storage
|
||||
|
||||
await tagService.importTags(SEED_TAGS);
|
||||
}
|
||||
```
|
||||
|
||||
The seeding runs once on application startup. If any tags already exist, seeding is skipped entirely. Users can reset to defaults from the Tag Manager module.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Module Usage
|
||||
|
||||
Every module that supports tagging stores tag IDs as a `string[]` on its entities. The actual tag data lives in the shared tag storage, not duplicated per module.
|
||||
|
||||
### Registratura
|
||||
|
||||
```typescript
|
||||
interface RegistryEntry {
|
||||
id: string;
|
||||
// ... other fields
|
||||
tagIds: string[]; // typically: project + document-type + phase tags
|
||||
}
|
||||
```
|
||||
|
||||
**Typical tagging pattern:** A registry entry for a building permit application might carry tags `["076 Casa Copernicus", "DTAC", "Depunere", "Parte scrisa"]`.
|
||||
|
||||
### Prompt Generator
|
||||
|
||||
```typescript
|
||||
interface PromptTemplate {
|
||||
id: string;
|
||||
// ... other fields
|
||||
tagIds: string[]; // typically: activity + custom domain tags
|
||||
}
|
||||
```
|
||||
|
||||
**Typical tagging pattern:** An architectural prompt template might carry `["Design exterior", "Rendering"]`.
|
||||
|
||||
### IT Inventory
|
||||
|
||||
```typescript
|
||||
interface InventoryDevice {
|
||||
id: string;
|
||||
// ... other fields
|
||||
tagIds: string[]; // typically: company + location tags
|
||||
}
|
||||
```
|
||||
|
||||
**Typical tagging pattern:** A laptop entry might carry `["Beletage", "Birou Centru"]`.
|
||||
|
||||
### Address Book
|
||||
|
||||
```typescript
|
||||
interface Contact {
|
||||
id: string;
|
||||
// ... other fields
|
||||
tagIds: string[]; // typically: custom contact-type tags
|
||||
}
|
||||
```
|
||||
|
||||
**Typical tagging pattern:** A contact might carry `["Client", "Beletage"]` or `["Furnizor", "Materiale constructii"]`.
|
||||
|
||||
### Digital Signatures
|
||||
|
||||
```typescript
|
||||
interface SignatureRecord {
|
||||
id: string;
|
||||
// ... other fields
|
||||
tagIds: string[]; // typically: company + signer identity tags
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Pattern
|
||||
|
||||
All modules follow the same pattern for tag integration:
|
||||
|
||||
1. Entity has a `tagIds: string[]` field.
|
||||
2. Forms include a `<TagSelector>` for editing tags.
|
||||
3. List/table views include a `<TagFilter>` for filtering by tags.
|
||||
4. Detail views render tags as `<TagBadge>` components.
|
||||
5. The module's service does not resolve tag data -- the UI layer calls `TagService.getTag()` or uses the `useTagsById(ids)` hook.
|
||||
|
||||
```typescript
|
||||
// src/hooks/useTags.ts
|
||||
|
||||
/** Resolve an array of tag IDs into Tag objects */
|
||||
function useTagsById(tagIds: string[]): Tag[] {
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const tagService = getTagService();
|
||||
tagService.getAllTags().then((allTags) => {
|
||||
const tagMap = new Map(allTags.map((t) => [t.id, t]));
|
||||
setTags(tagIds.map((id) => tagMap.get(id)).filter(Boolean) as Tag[]);
|
||||
});
|
||||
}, [tagIds]);
|
||||
|
||||
return tags;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tag Auto-Suggest
|
||||
|
||||
The `TagSelector` component supports contextual auto-suggestion. When a module provides context about the current entity, the selector can prioritize relevant tags.
|
||||
|
||||
```typescript
|
||||
interface TagSuggestContext {
|
||||
moduleId: string; // which module is requesting suggestions
|
||||
existingTagIds: string[]; // tags already applied to the entity
|
||||
entityType?: string; // e.g., 'registry-entry', 'contact'
|
||||
}
|
||||
```
|
||||
|
||||
### Suggestion Algorithm
|
||||
|
||||
1. **Category affinity:** Suggest tags from categories most commonly used in this module. Registratura prioritizes `project`, `phase`, `document-type`. Address Book prioritizes `company`, `custom`.
|
||||
2. **Co-occurrence:** Tags frequently applied alongside the already-selected tags are ranked higher. If the user selects "076 Casa Copernicus", phase tags like "DTAC" and "PT" that have been co-applied with that project before are suggested first.
|
||||
3. **Recency:** Recently used tags (across all entities in the module) are ranked higher than stale ones.
|
||||
4. **Hierarchy:** If a parent tag is selected, its children are suggested. If "076 Casa Copernicus" is selected, its child phase tags are surfaced.
|
||||
|
||||
The suggestion ranking is computed client-side from the module's entity data and the global tag list. No server-side analytics or ML is involved.
|
||||
|
||||
---
|
||||
|
||||
## Import / Export
|
||||
|
||||
### Export Format (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"exportedAt": "2025-03-15T10:30:00Z",
|
||||
"tags": [
|
||||
{
|
||||
"label": "CU",
|
||||
"category": "phase",
|
||||
"scope": "global",
|
||||
"color": "#3b82f6",
|
||||
"parentId": null,
|
||||
"metadata": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Export strips `id`, `createdAt`, and `updatedAt` since these are regenerated on import. The `parentId` field uses the parent's `label` + `category` as a composite key for portability (since IDs are not stable across environments).
|
||||
|
||||
### Import Rules
|
||||
|
||||
- Duplicate detection: if a tag with the same `label` + `category` + `scope` already exists, the import skips it (no overwrite).
|
||||
- Parent resolution: `parentId` in the import file is a `label:category` reference, resolved to the actual ID after all tags are created.
|
||||
- Validation: all import entries are validated against the same rules as `createTag`. Invalid entries are skipped and reported.
|
||||
|
||||
### CSV Format
|
||||
|
||||
For simpler workflows, tags can be imported from CSV:
|
||||
|
||||
```csv
|
||||
label,category,scope,color,parentLabel,parentCategory
|
||||
CU,phase,global,#3b82f6,,
|
||||
Redactare,activity,global,#6366f1,CU,phase
|
||||
```
|
||||
684
docs/guides/CODING-STANDARDS.md
Normal file
684
docs/guides/CODING-STANDARDS.md
Normal file
@@ -0,0 +1,684 @@
|
||||
# Coding Standards
|
||||
|
||||
Conventions and rules for all code in the ArchiTools repository.
|
||||
|
||||
---
|
||||
|
||||
## Language
|
||||
|
||||
TypeScript in strict mode. The `tsconfig.json` must include:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**No `any`** unless there is a documented, unavoidable reason (e.g., third-party library with missing types). When `any` is truly necessary, use `// eslint-disable-next-line @typescript-eslint/no-explicit-any` with a comment explaining why.
|
||||
|
||||
Prefer `unknown` over `any` when the type is genuinely unknown. Narrow with type guards.
|
||||
|
||||
---
|
||||
|
||||
## File Naming
|
||||
|
||||
| Entity | Convention | Example |
|
||||
|---|---|---|
|
||||
| Files (components) | kebab-case | `signature-preview.tsx` |
|
||||
| Files (hooks) | kebab-case with `use-` prefix | `use-signature-builder.ts` |
|
||||
| Files (utils/services) | kebab-case | `sanitize-xml-name.ts` |
|
||||
| Files (types) | `types.ts` within module | `types.ts` |
|
||||
| Files (tests) | `*.test.ts` / `*.test.tsx` | `sanitize-xml-name.test.ts` |
|
||||
| Component names | PascalCase | `SignaturePreview` |
|
||||
| Hook names | camelCase with `use` prefix | `useSignatureBuilder` |
|
||||
| Utility functions | camelCase | `sanitizeXmlName` |
|
||||
| Constants | UPPER_SNAKE_CASE | `DEFAULT_NAMESPACE` |
|
||||
| Type/Interface names | PascalCase | `SignatureConfig` |
|
||||
| Enum values | PascalCase | `FieldVariant.Upper` |
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Functional Components Only
|
||||
|
||||
No class components. All components are functions.
|
||||
|
||||
```tsx
|
||||
// Correct
|
||||
export function SignaturePreview({ config }: SignaturePreviewProps) {
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
// Wrong: arrow function export (less debuggable in stack traces)
|
||||
export const SignaturePreview = ({ config }: SignaturePreviewProps) => { ... };
|
||||
|
||||
// Wrong: default export
|
||||
export default function SignaturePreview() { ... }
|
||||
```
|
||||
|
||||
Exception: arrow functions are acceptable for small inline components passed as props or used in `.map()`.
|
||||
|
||||
### Props Interface
|
||||
|
||||
Define the props interface directly above the component, in the same file. Suffix with `Props`.
|
||||
|
||||
```tsx
|
||||
interface SignaturePreviewProps {
|
||||
config: SignatureConfig;
|
||||
className?: string;
|
||||
onExport?: () => void;
|
||||
}
|
||||
|
||||
export function SignaturePreview({ config, className, onExport }: SignaturePreviewProps) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
If the props interface is reused across multiple files, define it in the module's `types.ts` and import it.
|
||||
|
||||
### Named Exports
|
||||
|
||||
All exports are named. No default exports anywhere in the codebase.
|
||||
|
||||
Rationale: named exports enforce consistent import names, improve refactoring, and work better with tree-shaking.
|
||||
|
||||
```tsx
|
||||
// Correct
|
||||
export function SignaturePreview() { ... }
|
||||
export function useSignatureBuilder() { ... }
|
||||
export interface SignatureConfig { ... }
|
||||
|
||||
// Wrong
|
||||
export default function SignaturePreview() { ... }
|
||||
```
|
||||
|
||||
### Co-located Styles
|
||||
|
||||
All styling is done with Tailwind classes in JSX. No CSS modules, no styled-components, no separate `.css` files per component.
|
||||
|
||||
```tsx
|
||||
// Correct
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm">
|
||||
|
||||
// Wrong: external CSS file
|
||||
import styles from './signature-preview.module.css';
|
||||
<div className={styles.card}>
|
||||
```
|
||||
|
||||
Global styles live only in `src/app/globals.css` (theme tokens, font imports, base resets).
|
||||
|
||||
### Conditional Classes
|
||||
|
||||
Use the `cn()` utility (from `src/shared/utils/cn.ts`, wrapping `clsx` + `tailwind-merge`):
|
||||
|
||||
```tsx
|
||||
import { cn } from '@/shared/utils/cn';
|
||||
|
||||
<div className={cn(
|
||||
'rounded-xl border p-6',
|
||||
isActive && 'border-primary bg-primary/5',
|
||||
className
|
||||
)} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hook Patterns
|
||||
|
||||
### Naming
|
||||
|
||||
All hooks start with `use`. File names match: `use-signature-builder.ts` exports `useSignatureBuilder`.
|
||||
|
||||
### Return Type
|
||||
|
||||
Return a typed object, not a tuple/array.
|
||||
|
||||
```tsx
|
||||
// Correct
|
||||
export function useSignatureBuilder(config: SignatureConfig): SignatureBuilderState {
|
||||
// ...
|
||||
return { html, previewData, isGenerating };
|
||||
}
|
||||
|
||||
// Wrong: tuple return (positional args are fragile)
|
||||
export function useSignatureBuilder(config: SignatureConfig) {
|
||||
return [html, previewData, isGenerating];
|
||||
}
|
||||
```
|
||||
|
||||
Exception: simple two-value hooks that mirror `useState` semantics may return a tuple if the pattern is unambiguous: `const [value, setValue] = useSomeState()`.
|
||||
|
||||
### Single Responsibility
|
||||
|
||||
Each hook does one thing. If a hook grows beyond ~80 lines, consider splitting.
|
||||
|
||||
```
|
||||
useSignatureConfig -- manages form state
|
||||
useSignatureBuilder -- generates HTML from config
|
||||
useSignatureExport -- handles file download
|
||||
```
|
||||
|
||||
Not:
|
||||
|
||||
```
|
||||
useSignature -- does everything
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
Hooks that depend on external services (storage, API) receive them as parameters:
|
||||
|
||||
```tsx
|
||||
export function useCategoryManager(storage: StorageAdapter): CategoryManagerState {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This enables testing with mock storage.
|
||||
|
||||
---
|
||||
|
||||
## Service Patterns
|
||||
|
||||
Services are modules that encapsulate business logic outside of React's component lifecycle.
|
||||
|
||||
### Pure Functions Where Possible
|
||||
|
||||
```tsx
|
||||
// Correct: pure function, easy to test
|
||||
export function sanitizeXmlName(name: string): string | null {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Correct: pure function with explicit dependencies
|
||||
export function generateXml(input: XmlGeneratorInput): XmlGeneratorResult {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Accept Dependencies via Parameters
|
||||
|
||||
```tsx
|
||||
// Correct: zip library passed in (or imported at module level, testable via jest.mock)
|
||||
export async function createZipArchive(
|
||||
files: Record<string, string>,
|
||||
zipFactory: () => JSZip = () => new JSZip()
|
||||
): Promise<Blob> {
|
||||
const zip = zipFactory();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### No Direct DOM Access
|
||||
|
||||
Services must never call `document.*`, `window.localStorage`, or any browser API directly. All browser interactions are mediated through hooks or adapters that are injected.
|
||||
|
||||
```tsx
|
||||
// Wrong: service directly accessing DOM
|
||||
export function downloadFile(content: string) {
|
||||
const a = document.createElement('a'); // NO
|
||||
// ...
|
||||
}
|
||||
|
||||
// Correct: service returns data, hook handles DOM interaction
|
||||
export function prepareDownload(content: string, filename: string): DownloadPayload {
|
||||
return { blob: new Blob([content]), filename };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import Ordering
|
||||
|
||||
Imports are grouped in this order, separated by blank lines:
|
||||
|
||||
```tsx
|
||||
// 1. React
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
// 2. Next.js
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// 3. Third-party libraries
|
||||
import JSZip from 'jszip';
|
||||
import { z } from 'zod';
|
||||
|
||||
// 4. Core (@/core)
|
||||
import { labels } from '@/core/i18n/labels';
|
||||
import { useStorage } from '@/core/storage/use-storage';
|
||||
|
||||
// 5. Shared (@/shared)
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { cn } from '@/shared/utils/cn';
|
||||
|
||||
// 6. Module-level (@/modules)
|
||||
import { useXmlGenerator } from '@/modules/xml-generator/hooks/use-xml-generator';
|
||||
|
||||
// 7. Relative imports (same module)
|
||||
import { SignaturePreview } from './signature-preview';
|
||||
import type { SignatureConfig } from '../types';
|
||||
```
|
||||
|
||||
Enforce with ESLint `import/order` rule.
|
||||
|
||||
---
|
||||
|
||||
## Path Aliases
|
||||
|
||||
Configured in `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/core/*": ["./src/core/*"],
|
||||
"@/shared/*": ["./src/shared/*"],
|
||||
"@/modules/*": ["./src/modules/*"],
|
||||
"@/config/*": ["./src/config/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Always use path aliases for cross-boundary imports.
|
||||
- Use relative imports only within the same module directory.
|
||||
- Never use `../../../` chains that cross module boundaries.
|
||||
|
||||
```tsx
|
||||
// Correct: cross-boundary via alias
|
||||
import { labels } from '@/core/i18n/labels';
|
||||
|
||||
// Correct: within same module via relative
|
||||
import { SignaturePreview } from './signature-preview';
|
||||
|
||||
// Wrong: relative path crossing module boundary
|
||||
import { labels } from '../../../core/i18n/labels';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No Barrel Re-exports from Module Boundaries
|
||||
|
||||
Do not create `index.ts` files that re-export everything from a module.
|
||||
|
||||
```
|
||||
// WRONG: src/modules/xml-generator/index.ts
|
||||
export * from './hooks/use-xml-generator';
|
||||
export * from './hooks/use-category-manager';
|
||||
export * from './utils/sanitize-xml-name';
|
||||
export * from './types';
|
||||
```
|
||||
|
||||
Rationale: barrel files break tree-shaking in Next.js, cause circular dependency issues, and make import costs invisible. Import directly from the specific file.
|
||||
|
||||
```tsx
|
||||
// Correct: direct import
|
||||
import { useXmlGenerator } from '@/modules/xml-generator/hooks/use-xml-generator';
|
||||
|
||||
// Wrong: barrel import
|
||||
import { useXmlGenerator } from '@/modules/xml-generator';
|
||||
```
|
||||
|
||||
Exception: `src/shared/components/ui/` may use barrel re-exports since shadcn/ui generates them and they are leaf components with no circular dependency risk.
|
||||
|
||||
---
|
||||
|
||||
## Type Conventions
|
||||
|
||||
### `interface` vs `type`
|
||||
|
||||
- Use `interface` for object shapes (props, configs, data models).
|
||||
- Use `type` for unions, intersections, mapped types, and utility types.
|
||||
|
||||
```tsx
|
||||
// Object shape: interface
|
||||
interface SignatureConfig {
|
||||
prefix: string;
|
||||
name: string;
|
||||
colors: ColorMap;
|
||||
}
|
||||
|
||||
// Union: type
|
||||
type FieldVariant = 'base' | 'short' | 'upper' | 'lower' | 'initials' | 'first';
|
||||
|
||||
// Intersection: type
|
||||
type SignatureWithMeta = SignatureConfig & { id: string; createdAt: string };
|
||||
|
||||
// Mapped type: type
|
||||
type PartialSignature = Partial<SignatureConfig>;
|
||||
```
|
||||
|
||||
### Naming Suffixes
|
||||
|
||||
| Suffix | Usage | Example |
|
||||
|---|---|---|
|
||||
| `Props` | Component props | `SignaturePreviewProps` |
|
||||
| `State` | Hook return types | `SignatureBuilderState` |
|
||||
| `Result` | Service return types | `XmlGeneratorResult` |
|
||||
| `Config` | Configuration objects | `SignatureConfig` |
|
||||
| `Input` | Service/function input parameters | `XmlGeneratorInput` |
|
||||
|
||||
### Enums
|
||||
|
||||
Prefer union types over TypeScript enums for simple value sets:
|
||||
|
||||
```tsx
|
||||
// Preferred
|
||||
type GeneratorMode = 'simple' | 'advanced';
|
||||
|
||||
// Acceptable for larger sets with associated logic
|
||||
enum FieldVariant {
|
||||
Base = 'base',
|
||||
Short = 'short',
|
||||
Upper = 'upper',
|
||||
Lower = 'lower',
|
||||
Initials = 'initials',
|
||||
First = 'first',
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Result Pattern for Services
|
||||
|
||||
Service functions that can fail return a discriminated union instead of throwing:
|
||||
|
||||
```tsx
|
||||
type Result<T, E = Error> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: E };
|
||||
|
||||
export function parseXmlConfig(raw: string): Result<XmlConfig> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
// validate...
|
||||
return { success: true, data: parsed };
|
||||
} catch (e) {
|
||||
return { success: false, error: new Error('Invalid configuration format') };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### try/catch at Boundaries
|
||||
|
||||
Use `try/catch` only at the boundary between services and UI (in hooks or event handlers):
|
||||
|
||||
```tsx
|
||||
export function useXmlExport() {
|
||||
const handleDownload = useCallback(async () => {
|
||||
try {
|
||||
const blob = await createZipArchive(files);
|
||||
triggerDownload(blob, filename);
|
||||
} catch (error) {
|
||||
toast.error(labels.common.exportFailed);
|
||||
}
|
||||
}, [files, filename]);
|
||||
|
||||
return { handleDownload };
|
||||
}
|
||||
```
|
||||
|
||||
Do not scatter `try/catch` through utility functions. Let errors propagate to the boundary.
|
||||
|
||||
### Never Swallow Errors Silently
|
||||
|
||||
```tsx
|
||||
// Wrong: silent failure
|
||||
try { storage.set('key', data); } catch (e) {}
|
||||
|
||||
// Correct: at minimum, log
|
||||
try {
|
||||
storage.set('key', data);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist data:', error);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comments
|
||||
|
||||
### When to Comment
|
||||
|
||||
- Non-obvious business logic (e.g., "POT = SuprafataConstruitaLaSol / SuprafataTeren per Romanian building code").
|
||||
- Workarounds for known issues (link to issue/PR).
|
||||
- Regex patterns (always explain what the regex matches).
|
||||
- Magic numbers that cannot be replaced with named constants.
|
||||
|
||||
### When Not to Comment
|
||||
|
||||
- Self-explanatory code. If the function is `sanitizeXmlName`, do not add `/** Sanitizes an XML name */`.
|
||||
- JSDoc on every function. Add JSDoc only on public API boundaries of shared utilities.
|
||||
- TODO comments without an owner or issue reference.
|
||||
|
||||
### Comment Style
|
||||
|
||||
```tsx
|
||||
// Single-line comments for inline explanations.
|
||||
|
||||
/**
|
||||
* Multi-line JSDoc only for shared utility public API.
|
||||
* Describe what the function does, not how.
|
||||
*/
|
||||
export function formatPhoneNumber(raw: string): PhoneFormatResult {
|
||||
// Romanian mobile numbers: 07xx xxx xxx -> +40 7xx xxx xxx
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Romanian Text
|
||||
|
||||
All user-facing Romanian text lives exclusively in `src/core/i18n/labels.ts`.
|
||||
|
||||
**Components never contain inline Romanian strings:**
|
||||
|
||||
```tsx
|
||||
// Wrong
|
||||
<Button>Descarca HTML</Button>
|
||||
|
||||
// Correct
|
||||
import { labels } from '@/core/i18n/labels';
|
||||
<Button>{labels.signature.exportHtml}</Button>
|
||||
```
|
||||
|
||||
**Exceptions:**
|
||||
|
||||
- Test files may contain Romanian strings as test fixtures.
|
||||
- Data files (presets, defaults) may contain Romanian field names that are domain terms, not UI labels.
|
||||
|
||||
Romanian diacritics in label values: use them where correct (`Semnatura` vs `Semnatura` -- prefer with diacritics in label constants if the display context supports it). For technical identifiers (XML field names, CSS classes, file paths), never use diacritics.
|
||||
|
||||
---
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
**Types:**
|
||||
|
||||
| Type | Usage |
|
||||
|---|---|
|
||||
| `feat` | New feature |
|
||||
| `fix` | Bug fix |
|
||||
| `refactor` | Code restructuring without behavior change |
|
||||
| `docs` | Documentation only |
|
||||
| `style` | Formatting, whitespace (not CSS) |
|
||||
| `test` | Adding or fixing tests |
|
||||
| `chore` | Build, tooling, dependencies |
|
||||
| `perf` | Performance improvement |
|
||||
|
||||
**Scope:** module name or area (`signature`, `xml-generator`, `core`, `ui`, `config`).
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
feat(signature): add multi-company support to signature builder
|
||||
fix(xml-generator): handle duplicate field names in category
|
||||
refactor(core): extract storage abstraction from localStorage calls
|
||||
docs(guides): add HTML tool integration plan
|
||||
chore(deps): upgrade shadcn/ui to 2.1.0
|
||||
```
|
||||
|
||||
### Branching
|
||||
|
||||
- `main` -- production-ready code. Protected. No direct pushes.
|
||||
- `feature/<description>` -- feature branches, branched from `main`.
|
||||
- `fix/<description>` -- bugfix branches.
|
||||
- `chore/<description>` -- maintenance branches.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- One logical change per PR.
|
||||
- PR title follows conventional commit format.
|
||||
- PR description includes: what changed, why, how to test.
|
||||
- All CI checks must pass before merge.
|
||||
- Squash merge to `main` (clean linear history).
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
The following patterns are explicitly prohibited in this codebase. Each entry includes the reason and the correct alternative.
|
||||
|
||||
### 1. `any` as escape hatch
|
||||
|
||||
```tsx
|
||||
// Wrong
|
||||
const data: any = fetchSomething();
|
||||
|
||||
// Correct
|
||||
const data: unknown = fetchSomething();
|
||||
if (isValidConfig(data)) { /* narrow type */ }
|
||||
```
|
||||
|
||||
### 2. Default exports
|
||||
|
||||
```tsx
|
||||
// Wrong
|
||||
export default function Page() { ... }
|
||||
|
||||
// Correct (Next.js pages are the sole exception -- App Router requires default exports for page.tsx/layout.tsx)
|
||||
// For page.tsx and layout.tsx ONLY, default export is acceptable because Next.js requires it.
|
||||
export default function Page() { ... }
|
||||
|
||||
// Everything else: named exports
|
||||
export function SignatureForm() { ... }
|
||||
```
|
||||
|
||||
### 3. Direct DOM manipulation in components
|
||||
|
||||
```tsx
|
||||
// Wrong
|
||||
useEffect(() => {
|
||||
document.getElementById('preview')!.innerHTML = html;
|
||||
}, [html]);
|
||||
|
||||
// Correct
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
// Or better: render structured JSX from data
|
||||
```
|
||||
|
||||
### 4. Inline Romanian strings
|
||||
|
||||
```tsx
|
||||
// Wrong
|
||||
<Label>Nume si Prenume</Label>
|
||||
|
||||
// Correct
|
||||
<Label>{labels.signature.fieldName}</Label>
|
||||
```
|
||||
|
||||
### 5. God hooks
|
||||
|
||||
```tsx
|
||||
// Wrong: hook that manages 15 pieces of state and 10 callbacks
|
||||
export function useEverything() { ... }
|
||||
|
||||
// Correct: split by responsibility
|
||||
export function useSignatureConfig() { ... }
|
||||
export function useSignatureBuilder() { ... }
|
||||
export function useSignatureExport() { ... }
|
||||
```
|
||||
|
||||
### 6. Barrel re-exports at module boundary
|
||||
|
||||
```tsx
|
||||
// Wrong: src/modules/xml-generator/index.ts
|
||||
export * from './hooks/use-xml-generator';
|
||||
|
||||
// Correct: import directly from source file
|
||||
import { useXmlGenerator } from '@/modules/xml-generator/hooks/use-xml-generator';
|
||||
```
|
||||
|
||||
### 7. Business logic in components
|
||||
|
||||
```tsx
|
||||
// Wrong: XML sanitization logic inline in JSX
|
||||
function XmlForm() {
|
||||
const sanitize = (name: string) => {
|
||||
let n = name.replace(/\s+/g, '_');
|
||||
// 20 more lines of logic...
|
||||
};
|
||||
}
|
||||
|
||||
// Correct: extract to utility, test separately
|
||||
import { sanitizeXmlName } from '../utils/sanitize-xml-name';
|
||||
```
|
||||
|
||||
### 8. Untyped hook returns
|
||||
|
||||
```tsx
|
||||
// Wrong: caller has to guess the shape
|
||||
export function useConfig() {
|
||||
return [config, setConfig, isLoading, error];
|
||||
}
|
||||
|
||||
// Correct: explicit interface
|
||||
interface ConfigState {
|
||||
config: Config;
|
||||
setConfig: (c: Config) => void;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
export function useConfig(): ConfigState { ... }
|
||||
```
|
||||
|
||||
### 9. `useEffect` for derived state
|
||||
|
||||
```tsx
|
||||
// Wrong: effect to compute a value from props
|
||||
const [fullName, setFullName] = useState('');
|
||||
useEffect(() => {
|
||||
setFullName(`${prefix} ${name}`);
|
||||
}, [prefix, name]);
|
||||
|
||||
// Correct: derive during render
|
||||
const fullName = `${prefix} ${name}`;
|
||||
// Or useMemo if computation is expensive
|
||||
const fullName = useMemo(() => expensiveFormat(prefix, name), [prefix, name]);
|
||||
```
|
||||
|
||||
### 10. Committing secrets or environment files
|
||||
|
||||
Never commit `.env`, `.env.local`, credentials, API keys, or tokens. The `.gitignore` must include these patterns. If a secret is accidentally committed, rotate it immediately -- do not just delete the file.
|
||||
622
docs/guides/CONFIGURATION.md
Normal file
622
docs/guides/CONFIGURATION.md
Normal file
@@ -0,0 +1,622 @@
|
||||
# Configuration Guide
|
||||
|
||||
> How ArchiTools is configured at build time, runtime, and per-module.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All environment variables are defined in `.env.local` for development and injected via Docker for production. The `.env.example` file in the repository root documents every variable:
|
||||
|
||||
```bash
|
||||
# =============================================================================
|
||||
# ArchiTools Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env.local for development.
|
||||
# For Docker, pass variables via docker-compose.yml or Portainer.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Application
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_APP_NAME=ArchiTools
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Version displayed in footer/about. Set by CI or manually.
|
||||
NEXT_PUBLIC_APP_VERSION=0.1.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Storage
|
||||
# -----------------------------------------------------------------------------
|
||||
# Active storage adapter: 'localStorage' | 'api'
|
||||
NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
|
||||
|
||||
# REST API storage backend (required when STORAGE_ADAPTER=api)
|
||||
# STORAGE_API_URL=http://api.internal/storage
|
||||
|
||||
# MinIO object storage (server-side only, for file/binary storage)
|
||||
# MINIO_ENDPOINT=minio.internal:9000
|
||||
# MINIO_ACCESS_KEY=
|
||||
# MINIO_SECRET_KEY=
|
||||
# MINIO_BUCKET=architools
|
||||
# MINIO_USE_SSL=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Authentication (future — Authentik SSO)
|
||||
# -----------------------------------------------------------------------------
|
||||
# AUTHENTIK_URL=https://auth.internal
|
||||
# AUTHENTIK_CLIENT_ID=
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
# NEXTAUTH_URL=http://localhost:3000
|
||||
# NEXTAUTH_SECRET=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Feature Flags
|
||||
# -----------------------------------------------------------------------------
|
||||
# Comma-separated list of flag overrides. Format: flag_name=true/false
|
||||
# Example: NEXT_PUBLIC_FLAGS_OVERRIDE=module_ai_chat=true,module_password_vault=false
|
||||
NEXT_PUBLIC_FLAGS_OVERRIDE=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# External Services
|
||||
# -----------------------------------------------------------------------------
|
||||
# N8N webhook endpoint for automation triggers
|
||||
# N8N_WEBHOOK_URL=https://n8n.internal/webhook
|
||||
|
||||
# Gitea API (for potential repo integration)
|
||||
# GITEA_URL=https://git.internal
|
||||
# GITEA_TOKEN=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# External Tool Links (displayed on dashboard)
|
||||
# -----------------------------------------------------------------------------
|
||||
# NEXT_PUBLIC_PORTAINER_URL=https://portainer.internal
|
||||
# NEXT_PUBLIC_DOZZLE_URL=https://dozzle.internal
|
||||
# NEXT_PUBLIC_NETDATA_URL=https://netdata.internal
|
||||
# NEXT_PUBLIC_UPTIME_KUMA_URL=https://uptime.internal
|
||||
# NEXT_PUBLIC_IT_TOOLS_URL=https://it-tools.internal
|
||||
# NEXT_PUBLIC_STIRLING_PDF_URL=https://pdf.internal
|
||||
# NEXT_PUBLIC_FILEBROWSER_URL=https://files.internal
|
||||
# NEXT_PUBLIC_N8N_URL=https://n8n.internal
|
||||
# NEXT_PUBLIC_GITEA_URL=https://git.internal
|
||||
# NEXT_PUBLIC_MINIO_CONSOLE_URL=https://minio-console.internal
|
||||
```
|
||||
|
||||
### Naming Rules
|
||||
|
||||
| Prefix | Scope | Access |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_` | Client + Server | Bundled into client JS. Never put secrets here. |
|
||||
| No prefix | Server only | Available in API routes, server components, middleware. |
|
||||
|
||||
**Never put credentials, API keys, or secrets in `NEXT_PUBLIC_` variables.** They are embedded in the JavaScript bundle and visible to anyone with browser DevTools.
|
||||
|
||||
---
|
||||
|
||||
## Module Configuration
|
||||
|
||||
**File:** `src/config/modules.ts`
|
||||
|
||||
Defines the module registry. Every module in the system is declared here. Modules not in this registry do not exist to the platform.
|
||||
|
||||
```typescript
|
||||
// src/config/modules.ts
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface ModuleDefinition {
|
||||
/** Unique module identifier. Matches the directory name under src/modules/. */
|
||||
id: string;
|
||||
|
||||
/** Romanian display name shown in navigation and headers. */
|
||||
label: string;
|
||||
|
||||
/** Short Romanian description for tooltips and dashboard cards. */
|
||||
description: string;
|
||||
|
||||
/** Lucide icon component reference. */
|
||||
icon: LucideIcon;
|
||||
|
||||
/** URL path segment. Module is accessible at /modules/{path}. */
|
||||
path: string;
|
||||
|
||||
/** Storage namespace. Must match STORAGE-LAYER.md namespace table. */
|
||||
namespace: string;
|
||||
|
||||
/** Feature flag that controls activation. References flags.ts. */
|
||||
featureFlag: string;
|
||||
|
||||
/** Default enabled state when no flag override exists. */
|
||||
defaultEnabled: boolean;
|
||||
|
||||
/** Minimum role required to see this module. */
|
||||
minRole: Role;
|
||||
|
||||
/** Sort order in navigation sidebar. Lower = higher. */
|
||||
order: number;
|
||||
|
||||
/** If true, module is loaded only when navigated to. */
|
||||
lazy: boolean;
|
||||
|
||||
/** Which companies this module is relevant to. Empty = all. */
|
||||
companies: CompanyId[];
|
||||
}
|
||||
|
||||
export const MODULE_REGISTRY: ModuleDefinition[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Panou Principal',
|
||||
description: 'Tablou de bord cu widget-uri și acces rapid',
|
||||
icon: LayoutDashboard,
|
||||
path: 'dashboard',
|
||||
namespace: 'dashboard',
|
||||
featureFlag: 'module_dashboard',
|
||||
defaultEnabled: true,
|
||||
minRole: 'viewer',
|
||||
order: 0,
|
||||
lazy: false,
|
||||
companies: [],
|
||||
},
|
||||
{
|
||||
id: 'registratura',
|
||||
label: 'Registratură',
|
||||
description: 'Registru de intrări și ieșiri documente',
|
||||
icon: BookOpen,
|
||||
path: 'registratura',
|
||||
namespace: 'registratura',
|
||||
featureFlag: 'module_registratura',
|
||||
defaultEnabled: true,
|
||||
minRole: 'user',
|
||||
order: 10,
|
||||
lazy: true,
|
||||
companies: [],
|
||||
},
|
||||
// ... remaining modules follow same pattern
|
||||
];
|
||||
```
|
||||
|
||||
### Adding a New Module
|
||||
|
||||
1. Create the module directory: `src/modules/{module-id}/`.
|
||||
2. Add an entry to `MODULE_REGISTRY` in `src/config/modules.ts`.
|
||||
3. Add a feature flag in `src/config/flags.ts`.
|
||||
4. Add the namespace to the namespace table in the storage layer docs.
|
||||
5. Create the App Router page: `src/app/modules/{path}/page.tsx`.
|
||||
6. The navigation system and feature flag engine pick it up automatically.
|
||||
|
||||
---
|
||||
|
||||
## Feature Flag Configuration
|
||||
|
||||
**File:** `src/config/flags.ts`
|
||||
|
||||
```typescript
|
||||
// src/config/flags.ts
|
||||
|
||||
interface FeatureFlag {
|
||||
/** Flag identifier. Convention: module_{id} for module toggles, feature_{name} for features. */
|
||||
id: string;
|
||||
|
||||
/** Romanian description. */
|
||||
description: string;
|
||||
|
||||
/** Default value when no override exists. */
|
||||
defaultValue: boolean;
|
||||
|
||||
/** If true, flag is only visible in admin settings. */
|
||||
adminOnly: boolean;
|
||||
|
||||
/** If true, flag is experimental and shown with a warning badge. */
|
||||
experimental: boolean;
|
||||
}
|
||||
|
||||
export const FEATURE_FLAGS: FeatureFlag[] = [
|
||||
// Module flags
|
||||
{ id: 'module_dashboard', description: 'Panou principal', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_registratura', description: 'Registratură', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_email_signature', description: 'Generator semnătură email', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_word_xml', description: 'Generatoare Word XML', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_digital_signatures', description: 'Semnături și ștampile digitale', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_password_vault', description: 'Seif parole partajat', defaultValue: true, adminOnly: true, experimental: false },
|
||||
{ id: 'module_it_inventory', description: 'Inventar IT', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_address_book', description: 'Agendă de contacte', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_prompt_generator', description: 'Generator de prompturi AI', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_word_templates', description: 'Șabloane Word', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_tag_manager', description: 'Manager etichete', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_mini_utilities', description: 'Mini utilitare', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'module_ai_chat', description: 'Chat AI', defaultValue: false, adminOnly: false, experimental: true },
|
||||
|
||||
// Feature flags (non-module)
|
||||
{ id: 'feature_dark_mode', description: 'Mod întunecat', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'feature_export_import', description: 'Export/import date', defaultValue: true, adminOnly: true, experimental: false },
|
||||
{ id: 'feature_cross_tab_sync', description: 'Sincronizare între tab-uri', defaultValue: true, adminOnly: false, experimental: false },
|
||||
{ id: 'feature_infra_links', description: 'Linkuri infrastructură pe panou',defaultValue: true, adminOnly: false, experimental: false },
|
||||
];
|
||||
```
|
||||
|
||||
### Flag Resolution Order
|
||||
|
||||
1. **Environment override** (`NEXT_PUBLIC_FLAGS_OVERRIDE`): Highest priority. Parsed as comma-separated `key=value` pairs.
|
||||
2. **Runtime override** (stored in `system` namespace under key `flag-overrides`): Set through admin UI. Persists across sessions.
|
||||
3. **Default value** (`defaultValue` in `flags.ts`): Lowest priority. Used when no override exists.
|
||||
|
||||
```typescript
|
||||
// src/lib/flags/resolve.ts
|
||||
|
||||
function resolveFlag(flagId: string): boolean {
|
||||
// 1. Environment override
|
||||
const envOverrides = parseEnvOverrides(process.env.NEXT_PUBLIC_FLAGS_OVERRIDE);
|
||||
if (flagId in envOverrides) return envOverrides[flagId];
|
||||
|
||||
// 2. Runtime override (from storage)
|
||||
const runtimeOverrides = getRuntimeOverrides(); // from system namespace
|
||||
if (flagId in runtimeOverrides) return runtimeOverrides[flagId];
|
||||
|
||||
// 3. Default
|
||||
const flag = FEATURE_FLAGS.find(f => f.id === flagId);
|
||||
return flag?.defaultValue ?? false;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlag } from '@/lib/flags/hooks';
|
||||
|
||||
function SomeComponent() {
|
||||
const aiChatEnabled = useFeatureFlag('module_ai_chat');
|
||||
|
||||
if (!aiChatEnabled) return null;
|
||||
return <AIChatModule />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation Configuration
|
||||
|
||||
**File:** `src/config/navigation.ts`
|
||||
|
||||
Navigation is derived from the module registry. No separate navigation config is maintained. The navigation system reads `MODULE_REGISTRY`, filters by feature flags and role, sorts by `order`, and renders the sidebar.
|
||||
|
||||
```typescript
|
||||
// src/config/navigation.ts
|
||||
|
||||
import { MODULE_REGISTRY } from './modules';
|
||||
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
modules: string[]; // Module IDs belonging to this group
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups organize modules in the sidebar.
|
||||
* Modules not listed in any group appear under "Altele" (Others).
|
||||
*/
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Principal',
|
||||
modules: ['dashboard'],
|
||||
},
|
||||
{
|
||||
label: 'Documente',
|
||||
modules: ['registratura', 'word-templates', 'word-xml', 'email-signature'],
|
||||
},
|
||||
{
|
||||
label: 'Resurse',
|
||||
modules: ['address-book', 'digital-signatures', 'password-vault', 'it-inventory'],
|
||||
},
|
||||
{
|
||||
label: 'Instrumente',
|
||||
modules: ['prompt-generator', 'ai-chat', 'mini-utilities'],
|
||||
},
|
||||
{
|
||||
label: 'Administrare',
|
||||
modules: ['tag-manager'],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### External Links
|
||||
|
||||
Infrastructure tool links are configured via environment variables and displayed in a separate sidebar section or dashboard widget:
|
||||
|
||||
```typescript
|
||||
// src/config/navigation.ts
|
||||
|
||||
interface ExternalLink {
|
||||
label: string;
|
||||
envVar: string; // Environment variable holding the URL
|
||||
icon: LucideIcon;
|
||||
category: 'infra' | 'dev' | 'tools';
|
||||
}
|
||||
|
||||
export const EXTERNAL_LINKS: ExternalLink[] = [
|
||||
{ label: 'Portainer', envVar: 'NEXT_PUBLIC_PORTAINER_URL', icon: Container, category: 'infra' },
|
||||
{ label: 'Dozzle', envVar: 'NEXT_PUBLIC_DOZZLE_URL', icon: ScrollText, category: 'infra' },
|
||||
{ label: 'Netdata', envVar: 'NEXT_PUBLIC_NETDATA_URL', icon: Activity, category: 'infra' },
|
||||
{ label: 'Uptime Kuma', envVar: 'NEXT_PUBLIC_UPTIME_KUMA_URL', icon: HeartPulse, category: 'infra' },
|
||||
{ label: 'Gitea', envVar: 'NEXT_PUBLIC_GITEA_URL', icon: GitBranch, category: 'dev' },
|
||||
{ label: 'MinIO', envVar: 'NEXT_PUBLIC_MINIO_CONSOLE_URL', icon: Database, category: 'dev' },
|
||||
{ label: 'IT-Tools', envVar: 'NEXT_PUBLIC_IT_TOOLS_URL', icon: Wrench, category: 'tools' },
|
||||
{ label: 'Stirling PDF', envVar: 'NEXT_PUBLIC_STIRLING_PDF_URL', icon: FileText, category: 'tools' },
|
||||
{ label: 'N8N', envVar: 'NEXT_PUBLIC_N8N_URL', icon: Workflow, category: 'tools' },
|
||||
{ label: 'Filebrowser', envVar: 'NEXT_PUBLIC_FILEBROWSER_URL', icon: FolderOpen, category: 'tools' },
|
||||
];
|
||||
```
|
||||
|
||||
Links with no URL configured (empty env var) are hidden automatically.
|
||||
|
||||
---
|
||||
|
||||
## Company Configuration
|
||||
|
||||
**File:** `src/config/companies.ts`
|
||||
|
||||
Static company data. Updated rarely and only by developers.
|
||||
|
||||
```typescript
|
||||
// src/config/companies.ts
|
||||
|
||||
import type { CompanyId, Company } from '@/types/company';
|
||||
|
||||
export const COMPANIES: Record<CompanyId, Company> = {
|
||||
beletage: {
|
||||
id: 'beletage',
|
||||
name: 'Beletage SRL',
|
||||
shortName: 'Beletage',
|
||||
cui: 'RO12345678', // replace with real CUI
|
||||
address: '...',
|
||||
email: 'office@beletage.ro',
|
||||
phone: '...',
|
||||
},
|
||||
'urban-switch': {
|
||||
id: 'urban-switch',
|
||||
name: 'Urban Switch SRL',
|
||||
shortName: 'Urban Switch',
|
||||
cui: 'RO23456789',
|
||||
address: '...',
|
||||
email: 'office@urbanswitch.ro',
|
||||
phone: '...',
|
||||
},
|
||||
'studii-de-teren': {
|
||||
id: 'studii-de-teren',
|
||||
name: 'Studii de Teren SRL',
|
||||
shortName: 'Studii de Teren',
|
||||
cui: 'RO34567890',
|
||||
address: '...',
|
||||
email: 'office@studiideteren.ro',
|
||||
phone: '...',
|
||||
},
|
||||
group: {
|
||||
id: 'group',
|
||||
name: 'Grup Beletage',
|
||||
shortName: 'Grup',
|
||||
cui: '',
|
||||
},
|
||||
};
|
||||
|
||||
export const COMPANY_IDS: CompanyId[] = ['beletage', 'urban-switch', 'studii-de-teren'];
|
||||
export const ALL_COMPANY_IDS: CompanyId[] = [...COMPANY_IDS, 'group'];
|
||||
```
|
||||
|
||||
`COMPANY_IDS` excludes `'group'` for UI dropdowns where a real company selection is required. `ALL_COMPANY_IDS` includes it for contexts where "all companies" is valid.
|
||||
|
||||
---
|
||||
|
||||
## Theme Configuration
|
||||
|
||||
**File:** `src/config/theme.ts`
|
||||
|
||||
Theme tokens extend the shadcn/ui and Tailwind defaults.
|
||||
|
||||
```typescript
|
||||
// src/config/theme.ts
|
||||
|
||||
export const THEME_CONFIG = {
|
||||
/** Default theme on first visit. User preference is stored in system namespace. */
|
||||
defaultTheme: 'light' as 'light' | 'dark' | 'system',
|
||||
|
||||
/** Company brand colors for badges, indicators, and charts. */
|
||||
companyColors: {
|
||||
beletage: { primary: '#1E3A5F', accent: '#4A90D9' },
|
||||
'urban-switch': { primary: '#2D5F3E', accent: '#6BBF8A' },
|
||||
'studii-de-teren': { primary: '#5F4B1E', accent: '#D9A44A' },
|
||||
group: { primary: '#374151', accent: '#6B7280' },
|
||||
},
|
||||
|
||||
/** Tag category default colors. */
|
||||
tagCategoryColors: {
|
||||
project: '#3B82F6',
|
||||
client: '#8B5CF6',
|
||||
phase: '#F59E0B',
|
||||
type: '#10B981',
|
||||
priority: '#EF4444',
|
||||
domain: '#6366F1',
|
||||
custom: '#6B7280',
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
Theme switching is handled by `next-themes` (shadcn/ui standard). The user's preference is stored in the `system` storage namespace under the key `theme-preference`.
|
||||
|
||||
### Tailwind Integration
|
||||
|
||||
Company colors and tag colors are registered in `tailwind.config.ts` as extended colors:
|
||||
|
||||
```typescript
|
||||
// tailwind.config.ts (relevant excerpt)
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
beletage: { DEFAULT: '#1E3A5F', accent: '#4A90D9' },
|
||||
urbanswitch: { DEFAULT: '#2D5F3E', accent: '#6BBF8A' },
|
||||
studiideteren: { DEFAULT: '#5F4B1E', accent: '#D9A44A' },
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build-Time vs Runtime Configuration
|
||||
|
||||
| Configuration Type | When Resolved | How Set | Changeable Without Rebuild |
|
||||
|---|---|---|---|
|
||||
| `NEXT_PUBLIC_*` env vars | Build time (bundled into JS) | `.env.local`, Docker build args | No (requires rebuild) |
|
||||
| Server-only env vars | Runtime (read on each request) | Docker env vars, `.env.local` | Yes |
|
||||
| Feature flag defaults | Build time (in `flags.ts`) | Source code | No |
|
||||
| Feature flag overrides (env) | Build time (via `NEXT_PUBLIC_FLAGS_OVERRIDE`) | Env var | No |
|
||||
| Feature flag overrides (runtime) | Runtime (from storage) | Admin UI | Yes |
|
||||
| Module registry | Build time (in `modules.ts`) | Source code | No |
|
||||
| Company data | Build time (in `companies.ts`) | Source code | No |
|
||||
| Theme preference | Runtime (from storage) | User toggle | Yes |
|
||||
| External tool URLs | Build time (via `NEXT_PUBLIC_*`) | Env vars | No |
|
||||
|
||||
### Making `NEXT_PUBLIC_` Variables Runtime-Configurable in Docker
|
||||
|
||||
Next.js inlines `NEXT_PUBLIC_` variables at build time, which is problematic for Docker images that should be configurable at deploy time. Solution:
|
||||
|
||||
**1. Build with placeholder values:**
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
ARG NEXT_PUBLIC_APP_URL=__NEXT_PUBLIC_APP_URL__
|
||||
ARG NEXT_PUBLIC_STORAGE_ADAPTER=__NEXT_PUBLIC_STORAGE_ADAPTER__
|
||||
```
|
||||
|
||||
**2. Replace at container start:**
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
# docker/entrypoint.sh
|
||||
|
||||
# Replace build-time placeholders with runtime environment values
|
||||
find /app/.next -type f -name '*.js' | while read file; do
|
||||
sed -i "s|__NEXT_PUBLIC_APP_URL__|${NEXT_PUBLIC_APP_URL:-http://localhost:3000}|g" "$file"
|
||||
sed -i "s|__NEXT_PUBLIC_STORAGE_ADAPTER__|${NEXT_PUBLIC_STORAGE_ADAPTER:-localStorage}|g" "$file"
|
||||
# ... repeat for each NEXT_PUBLIC_ variable
|
||||
done
|
||||
|
||||
exec node server.js
|
||||
```
|
||||
|
||||
**3. Use in docker-compose:**
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
architools:
|
||||
image: architools:latest
|
||||
environment:
|
||||
- NEXT_PUBLIC_APP_URL=https://tools.internal
|
||||
- NEXT_PUBLIC_STORAGE_ADAPTER=api
|
||||
- STORAGE_API_URL=http://api:8080/storage
|
||||
- MINIO_ENDPOINT=minio:9000
|
||||
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Environment Injection
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# .env.local (gitignored)
|
||||
NEXT_PUBLIC_APP_NAME=ArchiTools
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
|
||||
```
|
||||
|
||||
### Production (Docker Compose)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
architools:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: architools
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_APP_NAME=ArchiTools
|
||||
- NEXT_PUBLIC_APP_URL=https://tools.internal
|
||||
- NEXT_PUBLIC_STORAGE_ADAPTER=api
|
||||
- STORAGE_API_URL=http://api:8080/storage
|
||||
- MINIO_ENDPOINT=minio:9000
|
||||
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||
- MINIO_BUCKET=architools
|
||||
# External links
|
||||
- NEXT_PUBLIC_PORTAINER_URL=https://portainer.internal
|
||||
- NEXT_PUBLIC_GITEA_URL=https://git.internal
|
||||
- NEXT_PUBLIC_N8N_URL=https://n8n.internal
|
||||
networks:
|
||||
- internal
|
||||
|
||||
networks:
|
||||
internal:
|
||||
external: true
|
||||
```
|
||||
|
||||
### Production (Portainer)
|
||||
|
||||
When deploying via Portainer, set environment variables in the container's Environment section. Sensitive values (MinIO keys, auth secrets) should use Portainer's secrets management rather than plain environment variables.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Precedence
|
||||
|
||||
Resolution order from lowest to highest priority:
|
||||
|
||||
```
|
||||
1. Source code defaults (flags.ts, modules.ts, companies.ts, theme.ts)
|
||||
↑ overridden by
|
||||
2. Environment variables (.env.local / Docker env)
|
||||
↑ overridden by
|
||||
3. Runtime overrides (admin UI → stored in system namespace)
|
||||
```
|
||||
|
||||
If a conflict exists, the higher-priority source wins. Runtime overrides are only available for feature flags and user preferences (theme, sidebar state). Structural configuration (module registry, company data, navigation groups) is not runtime-overridable; it requires a code change and rebuild.
|
||||
|
||||
---
|
||||
|
||||
## Configuration File Index
|
||||
|
||||
| File | Purpose | Changeable at Runtime |
|
||||
|---|---|---|
|
||||
| `src/config/modules.ts` | Module registry and metadata | No |
|
||||
| `src/config/flags.ts` | Feature flag definitions and defaults | Overridable via env/storage |
|
||||
| `src/config/navigation.ts` | Sidebar groups and external links | No |
|
||||
| `src/config/companies.ts` | Company master data | No |
|
||||
| `src/config/theme.ts` | Theme tokens and brand colors | Theme preference only |
|
||||
| `.env.local` | Development environment variables | N/A (dev only) |
|
||||
| `.env.example` | Documented variable template (committed) | N/A (reference) |
|
||||
| `docker-compose.yml` | Production environment variables | At deploy time |
|
||||
| `docker/entrypoint.sh` | Runtime placeholder replacement | At container start |
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
On application startup, the config system validates:
|
||||
|
||||
1. All required `NEXT_PUBLIC_` variables are set (not empty or placeholder).
|
||||
2. `NEXT_PUBLIC_STORAGE_ADAPTER` is a known adapter type.
|
||||
3. If adapter is `api`, `STORAGE_API_URL` is set.
|
||||
4. If MinIO is configured, all three of `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, and `MINIO_SECRET_KEY` are present.
|
||||
5. Feature flag overrides parse correctly (no malformed entries).
|
||||
6. Module IDs in `NAV_GROUPS` reference existing modules in `MODULE_REGISTRY`.
|
||||
|
||||
Validation errors are logged to the console with `[ArchiTools Config]` prefix and do not crash the application. Missing optional config results in graceful degradation (e.g., external links not shown, MinIO features unavailable).
|
||||
717
docs/guides/DOCKER-DEPLOYMENT.md
Normal file
717
docs/guides/DOCKER-DEPLOYMENT.md
Normal file
@@ -0,0 +1,717 @@
|
||||
# Docker Deployment Guide
|
||||
|
||||
> ArchiTools internal reference -- containerized deployment on the on-premise Ubuntu server.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ArchiTools runs as a single Docker container behind Nginx Proxy Manager on the internal network. The deployment pipeline is:
|
||||
|
||||
```
|
||||
Developer pushes to Gitea
|
||||
--> Portainer webhook triggers stack redeploy (or Watchtower detects image change)
|
||||
--> Docker builds multi-stage image
|
||||
--> Container starts on port 3000
|
||||
--> Nginx Proxy Manager routes tools.internal --> localhost:3000
|
||||
--> Users access via browser
|
||||
```
|
||||
|
||||
The container runs a standalone Next.js production server. No Node.js process manager (PM2, forever) is needed -- the container runtime handles restarts via `restart: unless-stopped`.
|
||||
|
||||
---
|
||||
|
||||
## Dockerfile
|
||||
|
||||
Multi-stage build that produces a minimal production image.
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Stage 2: Build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
### Stage Breakdown
|
||||
|
||||
| Stage | Base | Purpose | Output |
|
||||
|---|---|---|---|
|
||||
| `deps` | `node:20-alpine` | Install production dependencies only | `node_modules/` |
|
||||
| `builder` | `node:20-alpine` | Compile TypeScript, build Next.js bundle | `.next/standalone/`, `.next/static/`, `public/` |
|
||||
| `runner` | `node:20-alpine` | Minimal runtime image with non-root user | Final image (~120 MB) |
|
||||
|
||||
### Why Multi-Stage
|
||||
|
||||
- The `deps` stage caches `node_modules` independently of source code changes. If only application code changes, Docker reuses the cached dependency layer.
|
||||
- The `builder` stage contains all dev dependencies and source files but is discarded after the build.
|
||||
- The `runner` stage contains only the standalone server output, static assets, and public files. No `node_modules` directory, no source code, no dev tooling.
|
||||
|
||||
### Security Notes
|
||||
|
||||
- The `nextjs` user (UID 1001) is a non-root system user. The container never runs as root.
|
||||
- Alpine Linux has a minimal attack surface. No shell utilities beyond BusyBox basics.
|
||||
- The `NODE_ENV=production` flag disables React development warnings, enables Next.js production optimizations, and prevents accidental dev-mode behavior.
|
||||
|
||||
---
|
||||
|
||||
## next.config.ts Requirements
|
||||
|
||||
The standalone output mode is mandatory for the Docker deployment. Without it, Next.js expects the full `node_modules` directory at runtime.
|
||||
|
||||
```typescript
|
||||
// next.config.ts
|
||||
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
|
||||
// Required for Docker: trust the reverse proxy headers
|
||||
// so that Next.js resolves the correct protocol and host
|
||||
experimental: {
|
||||
// If needed in future Next.js versions
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
```
|
||||
|
||||
### What `output: 'standalone'` Does
|
||||
|
||||
1. Traces all required Node.js dependencies at build time.
|
||||
2. Copies only the needed files into `.next/standalone/`.
|
||||
3. Generates a self-contained `server.js` that starts a production HTTP server.
|
||||
4. Eliminates the need for `node_modules` in the runtime image.
|
||||
|
||||
The standalone output does **not** include the `public/` or `.next/static/` directories. These must be copied explicitly in the Dockerfile (which the Dockerfile above does).
|
||||
|
||||
---
|
||||
|
||||
## docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
architools:
|
||||
build: .
|
||||
container_name: architools
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_APP_URL=${APP_URL:-http://localhost:3000}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- architools-data:/app/data
|
||||
networks:
|
||||
- proxy-network
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
volumes:
|
||||
architools-data:
|
||||
|
||||
networks:
|
||||
proxy-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
### Field Reference
|
||||
|
||||
| Field | Purpose |
|
||||
|---|---|
|
||||
| `build: .` | Build from the Dockerfile in the repository root. |
|
||||
| `container_name: architools` | Fixed name for predictable Portainer/Dozzle references. |
|
||||
| `restart: unless-stopped` | Auto-restart on crash or server reboot. Only stops if explicitly stopped. |
|
||||
| `ports: "3000:3000"` | Map container port 3000 to host port 3000. Nginx Proxy Manager connects here. |
|
||||
| `env_file: .env` | Load environment variables from `.env`. Never committed to Gitea. |
|
||||
| `volumes: architools-data:/app/data` | Persistent volume for future server-side data. Not used in localStorage phase. |
|
||||
| `networks: proxy-network` | Shared Docker network with Nginx Proxy Manager and other services. |
|
||||
| `labels: watchtower.enable=true` | Opt in to Watchtower automatic image updates. |
|
||||
|
||||
### The `proxy-network` Network
|
||||
|
||||
All services that Nginx Proxy Manager routes to must be on the same Docker network. This network is created once and shared across all stacks:
|
||||
|
||||
```bash
|
||||
docker network create proxy-network
|
||||
```
|
||||
|
||||
If the network already exists (it should -- other services like Authentik, MinIO, N8N use it), the `external: true` declaration tells Docker Compose not to create it.
|
||||
|
||||
---
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### `.env` File
|
||||
|
||||
```bash
|
||||
# ──────────────────────────────────────────
|
||||
# Application
|
||||
# ──────────────────────────────────────────
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_APP_URL=https://tools.internal
|
||||
NEXT_PUBLIC_APP_ENV=production
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Feature Flags (override defaults from src/config/flags.ts)
|
||||
# ──────────────────────────────────────────
|
||||
NEXT_PUBLIC_FLAG_MODULE_REGISTRATURA=true
|
||||
NEXT_PUBLIC_FLAG_MODULE_PROMPT_GENERATOR=true
|
||||
NEXT_PUBLIC_FLAG_MODULE_EMAIL_SIGNATURE=true
|
||||
NEXT_PUBLIC_FLAG_MODULE_AI_CHAT=false
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Storage
|
||||
# ──────────────────────────────────────────
|
||||
NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
|
||||
|
||||
# Future: API backend
|
||||
# STORAGE_API_URL=http://localhost:4000/api/storage
|
||||
|
||||
# Future: MinIO
|
||||
# MINIO_ENDPOINT=minio.internal
|
||||
# MINIO_ACCESS_KEY=architools
|
||||
# MINIO_SECRET_KEY=<secret>
|
||||
# MINIO_BUCKET=architools
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Authentication (future: Authentik SSO)
|
||||
# ──────────────────────────────────────────
|
||||
# AUTHENTIK_ISSUER=https://auth.internal
|
||||
# AUTHENTIK_CLIENT_ID=architools
|
||||
# AUTHENTIK_CLIENT_SECRET=<secret>
|
||||
```
|
||||
|
||||
### Variable Scoping Rules
|
||||
|
||||
| Prefix | Available In | Notes |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_*` | Client + server | Inlined into the JavaScript bundle at build time. Visible to users in browser DevTools. Never put secrets here. |
|
||||
| No prefix | Server only | Available in API routes, middleware, server components. Used for secrets, credentials, internal URLs. |
|
||||
|
||||
### Build-Time vs. Runtime
|
||||
|
||||
`NEXT_PUBLIC_*` variables are baked into the bundle during `npm run build`. Changing them requires a rebuild. Non-prefixed variables are read at runtime and can be changed by restarting the container.
|
||||
|
||||
For Docker, this means:
|
||||
- `NEXT_PUBLIC_*` changes require rebuilding the image.
|
||||
- Server-only variables can be changed via Portainer environment editor and restarting the container.
|
||||
|
||||
---
|
||||
|
||||
## Nginx Proxy Manager Setup
|
||||
|
||||
### Proxy Host Configuration
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Domain Names** | `tools.internal` (or `tools.beletage.internal`, etc.) |
|
||||
| **Scheme** | `http` |
|
||||
| **Forward Hostname / IP** | `architools` (Docker container name, resolved via `proxy-network`) |
|
||||
| **Forward Port** | `3000` |
|
||||
| **Block Common Exploits** | Enabled |
|
||||
| **Websockets Support** | Enabled (for HMR in dev; harmless in production) |
|
||||
|
||||
### SSL Configuration
|
||||
|
||||
**Internal access (self-signed or internal CA):**
|
||||
|
||||
1. In Nginx Proxy Manager, go to SSL Certificates > Add SSL Certificate > Custom.
|
||||
2. Upload the internal CA certificate and key.
|
||||
3. Assign to the `tools.internal` proxy host.
|
||||
4. Browsers on internal machines must trust the internal CA (deployed via group policy or manual install).
|
||||
|
||||
**External access (Let's Encrypt):**
|
||||
|
||||
1. When the domain becomes publicly resolvable (e.g., `tools.beletage.ro`), switch to Let's Encrypt.
|
||||
2. In Nginx Proxy Manager, go to SSL Certificates > Add SSL Certificate > Let's Encrypt.
|
||||
3. Enter the domain, email, and agree to ToS.
|
||||
4. Nginx Proxy Manager handles renewal automatically.
|
||||
|
||||
### Security Headers
|
||||
|
||||
Add the following in the proxy host's Advanced tab (Custom Nginx Configuration):
|
||||
|
||||
```nginx
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Content Security Policy
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob:;
|
||||
font-src 'self' data:;
|
||||
connect-src 'self' https://api.openai.com https://api.anthropic.com;
|
||||
frame-ancestors 'self';
|
||||
" always;
|
||||
```
|
||||
|
||||
**Notes on CSP:**
|
||||
- `'unsafe-inline'` and `'unsafe-eval'` are required by Next.js in production. Tighten with nonces if migrating to a stricter CSP in the future.
|
||||
- `connect-src` includes AI provider API domains for the AI Chat and Prompt Generator modules. Adjust as providers are added or removed.
|
||||
- `frame-ancestors 'self'` prevents clickjacking (equivalent to `X-Frame-Options: SAMEORIGIN`).
|
||||
|
||||
---
|
||||
|
||||
## Portainer Deployment
|
||||
|
||||
### Stack Deployment from Gitea
|
||||
|
||||
1. In Portainer, go to Stacks > Add Stack.
|
||||
2. Select **Repository** as the build method.
|
||||
3. Configure:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Name** | `architools` |
|
||||
| **Repository URL** | `https://gitea.internal/beletage/architools.git` |
|
||||
| **Repository reference** | `refs/heads/main` |
|
||||
| **Compose path** | `docker-compose.yml` |
|
||||
| **Authentication** | Gitea access token or SSH key |
|
||||
|
||||
4. Under **Environment variables**, add all variables from the `.env` file. Portainer stores these securely and injects them at deploy time.
|
||||
5. Enable **Auto update** with a webhook if desired.
|
||||
|
||||
### Environment Variable Management
|
||||
|
||||
Portainer provides a UI for managing environment variables per stack. Use this for:
|
||||
|
||||
- Toggling feature flags without touching the repository.
|
||||
- Updating server-side secrets (MinIO keys, Authentik credentials) without rebuilding.
|
||||
- Switching `NEXT_PUBLIC_*` values (requires stack redeploy to rebuild the image).
|
||||
|
||||
**Important:** `NEXT_PUBLIC_*` variables are build-time constants. Changing them in Portainer requires redeploying the stack (which triggers a rebuild), not just restarting the container.
|
||||
|
||||
### Container Monitoring
|
||||
|
||||
Portainer provides:
|
||||
- **Container status:** running, stopped, restarting.
|
||||
- **Resource usage:** CPU, memory, network I/O.
|
||||
- **Logs:** stdout/stderr output (same as Dozzle, but accessible from the Portainer UI).
|
||||
- **Console:** exec into the container for debugging (use sparingly; the container has minimal tooling).
|
||||
- **Restart/Stop/Remove:** Manual container lifecycle controls.
|
||||
|
||||
---
|
||||
|
||||
## Watchtower Integration
|
||||
|
||||
Watchtower monitors Docker containers and automatically updates them when a new image is available.
|
||||
|
||||
### How It Works with ArchiTools
|
||||
|
||||
1. The `docker-compose.yml` includes the label `com.centurylinklabs.watchtower.enable=true`.
|
||||
2. Watchtower periodically checks (default: every 24 hours, configurable) if the image has changed.
|
||||
3. If a new image is detected, Watchtower:
|
||||
- Pulls the new image.
|
||||
- Stops the running container.
|
||||
- Creates a new container with the same configuration.
|
||||
- Starts the new container.
|
||||
- Removes the old image (if configured).
|
||||
|
||||
### Triggering Updates
|
||||
|
||||
**Automatic (Watchtower polling):** Watchtower polls at a configured interval. Suitable for non-urgent updates.
|
||||
|
||||
**Manual (Portainer):** Redeploy the stack from Portainer. This pulls the latest code from Gitea, rebuilds the image, and restarts the container.
|
||||
|
||||
**Webhook (Portainer):** Configure a Portainer webhook URL. Add it as a webhook in Gitea (triggered on push to `main`). Gitea pushes, Portainer receives the webhook, and redeploys.
|
||||
|
||||
### Recommended Flow
|
||||
|
||||
For ArchiTools, the primary deployment trigger is the **Portainer webhook from Gitea**:
|
||||
|
||||
```
|
||||
git push origin main
|
||||
--> Gitea fires webhook to Portainer
|
||||
--> Portainer redeploys the architools stack
|
||||
--> Docker rebuilds the image (multi-stage build)
|
||||
--> New container starts
|
||||
--> Old container removed
|
||||
```
|
||||
|
||||
Watchtower serves as a safety net for cases where the webhook fails or for updating the base `node:20-alpine` image.
|
||||
|
||||
---
|
||||
|
||||
## Health Check Endpoint
|
||||
|
||||
The application exposes a health check endpoint at `/api/health`.
|
||||
|
||||
```typescript
|
||||
// src/app/api/health/route.ts
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version ?? 'unknown',
|
||||
environment: process.env.NODE_ENV ?? 'unknown',
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
- **Uptime Kuma:** Add a monitor with type HTTP(s), URL `http://architools:3000/api/health`, expected status code `200`. Monitor interval: 60 seconds.
|
||||
- **Docker health check (optional):** Add to `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
architools:
|
||||
# ... existing config ...
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
|
||||
The `start_period` gives Next.js time to start before Docker begins health checking.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
### Strategy
|
||||
|
||||
ArchiTools logs to stdout and stderr. No file-based logging, no log rotation configuration inside the container. Docker captures all stdout/stderr output and makes it available via:
|
||||
|
||||
- **Dozzle:** Real-time log viewer. Access at `dozzle.internal`. Filter by container name `architools`.
|
||||
- **Portainer:** Logs tab on the container detail page.
|
||||
- **CLI:** `docker logs architools` or `docker logs -f architools` for live tail.
|
||||
|
||||
### Log Levels
|
||||
|
||||
| Source | Output | Captured By |
|
||||
|---|---|---|
|
||||
| Next.js server | Request logs, compilation warnings | stdout |
|
||||
| Application `console.log` | Debug information, state changes | stdout |
|
||||
| Application `console.error` | Errors, stack traces | stderr |
|
||||
| Unhandled exceptions | Crash traces | stderr |
|
||||
|
||||
### Structured Logging (Future)
|
||||
|
||||
When the application grows beyond simple console output, adopt a structured JSON logger (e.g., `pino`). This enables Dozzle or a future log aggregator to parse, filter, and search log entries by level, module, and context.
|
||||
|
||||
---
|
||||
|
||||
## Data Persistence Strategy
|
||||
|
||||
### Current Phase: localStorage
|
||||
|
||||
In the current phase, all module data lives in the browser's `localStorage`. The Docker container is stateless -- no server-side data storage. This means:
|
||||
|
||||
- **No data loss on container restart.** Data is in the browser, not the container.
|
||||
- **No backup needed for the container.** The volume mount (`architools-data:/app/data`) is provisioned but empty.
|
||||
- **No multi-user data sharing.** Each browser has its own isolated data set.
|
||||
- **Export/import is the backup mechanism.** Modules provide export buttons that download JSON files.
|
||||
|
||||
### Future Phase: Server-Side Storage
|
||||
|
||||
When the storage adapter switches to `api` or a database backend:
|
||||
|
||||
| Concern | Implementation |
|
||||
|---|---|
|
||||
| **Database** | PostgreSQL container on the same Docker network. Volume-mounted for persistence. |
|
||||
| **File storage** | MinIO (already running). ArchiTools stores file references in the database, binary objects in MinIO buckets. |
|
||||
| **Backup** | Database dumps + MinIO bucket sync. Scheduled via N8N or cron. |
|
||||
| **Volume mount** | `architools-data:/app/data` used for SQLite (if chosen as interim DB) or temp files. |
|
||||
|
||||
### Volume Mount
|
||||
|
||||
The `architools-data` volume is defined in `docker-compose.yml` and mounted at `/app/data`. It persists across container restarts and image rebuilds. Currently unused but ready for:
|
||||
|
||||
- SQLite database file (interim before PostgreSQL).
|
||||
- Temporary file processing (document generation, PDF manipulation).
|
||||
- Cache files if needed.
|
||||
|
||||
---
|
||||
|
||||
## Build and Deploy Workflow
|
||||
|
||||
### Full Lifecycle
|
||||
|
||||
```
|
||||
1. Developer pushes to Gitea (main branch)
|
||||
|
|
||||
2. Gitea fires webhook to Portainer
|
||||
|
|
||||
3. Portainer pulls latest code from Gitea repository
|
||||
|
|
||||
4. Docker builds multi-stage image:
|
||||
a. Stage 1 (deps): npm ci --only=production
|
||||
b. Stage 2 (builder): npm run build (Next.js standalone)
|
||||
c. Stage 3 (runner): minimal image with server.js
|
||||
|
|
||||
5. Portainer stops the running container
|
||||
|
|
||||
6. Portainer starts a new container from the fresh image
|
||||
|
|
||||
7. Health check passes (GET /api/health returns 200)
|
||||
|
|
||||
8. Nginx Proxy Manager routes traffic to the new container
|
||||
|
|
||||
9. Uptime Kuma confirms service is up
|
||||
|
|
||||
10. Old image is cleaned up (Watchtower or manual docker image prune)
|
||||
```
|
||||
|
||||
### Build Time Expectations
|
||||
|
||||
| Stage | Typical Duration | Notes |
|
||||
|---|---|---|
|
||||
| `deps` (cached) | <5 seconds | Only re-runs if `package.json` or `package-lock.json` changes. |
|
||||
| `deps` (fresh) | 30--60 seconds | Full `npm ci` with all dependencies. |
|
||||
| `builder` | 30--90 seconds | Next.js build. Depends on module count and TypeScript compilation. |
|
||||
| `runner` | <5 seconds | Just file copies. |
|
||||
| **Total (cached deps)** | ~1--2 minutes | Typical deployment time. |
|
||||
| **Total (fresh)** | ~2--3 minutes | After dependency changes. |
|
||||
|
||||
### Rollback
|
||||
|
||||
If a deployment introduces a bug:
|
||||
|
||||
1. In Portainer, stop the current container.
|
||||
2. Redeploy the stack pointing to the previous Gitea commit (change the repository reference to a specific commit SHA or tag).
|
||||
3. Alternatively, if the previous Docker image is still cached locally, restart the container from that image.
|
||||
|
||||
Tagging releases in Gitea (`v1.0.0`, `v1.1.0`) makes rollback straightforward.
|
||||
|
||||
---
|
||||
|
||||
## Development vs. Production Configuration
|
||||
|
||||
### Comparison
|
||||
|
||||
| Aspect | Development | Production |
|
||||
|---|---|---|
|
||||
| **Command** | `npm run dev` | `node server.js` (standalone) |
|
||||
| **Hot reload** | Yes (Fast Refresh) | No |
|
||||
| **Source maps** | Full | Minimal (production build) |
|
||||
| **NODE_ENV** | `development` | `production` |
|
||||
| **Storage adapter** | `localStorage` | `localStorage` (current), `api` (future) |
|
||||
| **Feature flags** | All enabled for testing | Selective per `.env` |
|
||||
| **Error display** | Full stack traces in browser | Generic error page |
|
||||
| **CSP headers** | None (permissive) | Strict (via Nginx Proxy Manager) |
|
||||
| **SSL** | None (`http://localhost:3000`) | Terminated at Nginx Proxy Manager |
|
||||
| **Docker** | Not used (direct `npm run dev`) | Multi-stage build, containerized |
|
||||
| **Port** | 3000 (direct) | 3000 (container) --> 443 (Nginx) |
|
||||
|
||||
### Running Development Locally
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
No Docker, no Nginx, no SSL. Just the Next.js dev server.
|
||||
|
||||
### Testing Production Build Locally
|
||||
|
||||
```bash
|
||||
# Build the production bundle
|
||||
npm run build
|
||||
|
||||
# Start the production server
|
||||
npm start
|
||||
|
||||
# Or test the Docker build
|
||||
docker build -t architools:local .
|
||||
docker run -p 3000:3000 --env-file .env architools:local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Fails to Start
|
||||
|
||||
**Symptom:** Container status shows `Restarting` in Portainer, or `docker ps` shows restart loop.
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
docker logs architools
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Error: Cannot find module './server.js'` | `output: 'standalone'` missing from `next.config.ts` | Add `output: 'standalone'` and rebuild. |
|
||||
| `EACCES: permission denied` | File ownership mismatch | Verify the Dockerfile copies files before switching to `USER nextjs`. |
|
||||
| `EADDRINUSE: port 3000` | Another container using port 3000 | Change the host port mapping in `docker-compose.yml` (e.g., `"3001:3000"`). |
|
||||
| `MODULE_NOT_FOUND` | Dependency not in production deps | Move the dependency from `devDependencies` to `dependencies` in `package.json`. |
|
||||
|
||||
### Build Fails at `npm run build`
|
||||
|
||||
**Symptom:** Docker build exits at the `builder` stage.
|
||||
|
||||
**Common causes:**
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|---|---|---|
|
||||
| TypeScript errors | Type mismatches in code | Fix TypeScript errors locally before pushing. |
|
||||
| `ENOMEM` | Not enough memory for build | Increase Docker memory limit (Next.js build can use 1--2 GB). |
|
||||
| Missing environment variables | `NEXT_PUBLIC_*` required at build time | Pass build args or set defaults in `next.config.ts`. |
|
||||
|
||||
### Application Returns 502 via Nginx
|
||||
|
||||
**Symptom:** Browser shows `502 Bad Gateway`.
|
||||
|
||||
**Checklist:**
|
||||
1. Is the container running? `docker ps | grep architools`
|
||||
2. Is the container healthy? `docker inspect architools | grep Health`
|
||||
3. Can Nginx reach the container? Both must be on `proxy-network`.
|
||||
4. Is the forward port correct (3000)?
|
||||
5. Is the scheme `http` (not `https` -- SSL terminates at Nginx)?
|
||||
|
||||
### Static Assets Not Loading (CSS, JS, Images)
|
||||
|
||||
**Symptom:** Page loads but unstyled, or browser console shows 404 for `/_next/static/*`.
|
||||
|
||||
**Cause:** Missing `COPY --from=builder /app/.next/static ./.next/static` in the Dockerfile.
|
||||
|
||||
**Fix:** Verify both `public/` and `.next/static/` are copied in the runner stage.
|
||||
|
||||
### Environment Variables Not Taking Effect
|
||||
|
||||
**Symptom:** Feature flag change in Portainer does not change behavior.
|
||||
|
||||
**Diagnosis:**
|
||||
- If the variable starts with `NEXT_PUBLIC_*`: it is baked in at build time. You must redeploy (rebuild the image), not just restart.
|
||||
- If the variable has no prefix: restart the container. The value is read at runtime.
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
**Symptom:** Container uses more than expected memory (check Portainer or Netdata).
|
||||
|
||||
**Typical usage:** 100--200 MB for a standalone Next.js server with moderate traffic.
|
||||
|
||||
**If higher:**
|
||||
- Check for memory leaks in server-side code (API routes, middleware).
|
||||
- Set a memory limit in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
architools:
|
||||
# ... existing config ...
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
### Logs Not Appearing in Dozzle
|
||||
|
||||
**Symptom:** Dozzle shows the container but no log output.
|
||||
|
||||
**Checklist:**
|
||||
1. Is the container actually running (not in a restart loop)?
|
||||
2. Is the application writing to stdout/stderr (not to a file)?
|
||||
3. Is Dozzle configured to monitor all containers on the Docker socket?
|
||||
|
||||
### Container Networking Issues
|
||||
|
||||
**Symptom:** Container cannot reach other services (MinIO, Authentik, N8N).
|
||||
|
||||
**Checklist:**
|
||||
1. All services must be on the same Docker network (`proxy-network`).
|
||||
2. Use container names as hostnames (e.g., `http://minio:9000`), not `localhost`.
|
||||
3. Verify DNS resolution: `docker exec architools wget -q -O- http://minio:9000/minio/health/live`
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t architools .
|
||||
|
||||
# Run container
|
||||
docker run -d --name architools -p 3000:3000 --env-file .env architools
|
||||
|
||||
# View logs
|
||||
docker logs -f architools
|
||||
|
||||
# Exec into container
|
||||
docker exec -it architools sh
|
||||
|
||||
# Rebuild and restart (compose)
|
||||
docker compose down && docker compose up -d --build
|
||||
|
||||
# Check health
|
||||
curl http://localhost:3000/api/health
|
||||
|
||||
# Prune old images
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
### File Checklist
|
||||
|
||||
| File | Required | Purpose |
|
||||
|---|---|---|
|
||||
| `Dockerfile` | Yes | Multi-stage build definition. |
|
||||
| `docker-compose.yml` | Yes | Service orchestration, networking, volumes. |
|
||||
| `.env` | Yes (not committed) | Environment variables. |
|
||||
| `.dockerignore` | Recommended | Exclude `node_modules`, `.git`, `.next` from build context. |
|
||||
| `next.config.ts` | Yes | Must include `output: 'standalone'`. |
|
||||
| `src/app/api/health/route.ts` | Yes | Health check endpoint. |
|
||||
|
||||
### `.dockerignore`
|
||||
|
||||
```
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
docs/
|
||||
.env
|
||||
.env.*
|
||||
```
|
||||
|
||||
This reduces the Docker build context size and prevents leaking sensitive files into the image.
|
||||
620
docs/guides/HTML-TOOL-INTEGRATION.md
Normal file
620
docs/guides/HTML-TOOL-INTEGRATION.md
Normal file
@@ -0,0 +1,620 @@
|
||||
# HTML Tool Integration Guide
|
||||
|
||||
How to migrate existing standalone HTML tools into React modules within the ArchiTools dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ArchiTools currently has four standalone HTML files that implement useful internal tools:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `emailsignature/emailsignature-config.html` | Email signature configurator with live preview, color pickers, layout sliders, HTML export |
|
||||
| `wordXMLgenerator/word-xml-generator-basic.html` | Simple Word XML Custom Part generator |
|
||||
| `wordXMLgenerator/word-xml-generator-medium.html` | Extended version with Short/Upper/Lower/Initials/First field variants |
|
||||
| `wordXMLgenerator/word-xml-generator-advanced.html` | Full version with categories, localStorage, simple/advanced mode, POT/CUT metrics, ZIP export |
|
||||
|
||||
### Why Integrate
|
||||
|
||||
Standalone HTML files work, but they cannot:
|
||||
|
||||
- Share a consistent UI theme (dark/light toggle, company branding).
|
||||
- Use shared storage abstraction (configurations saved in one tool are invisible to another).
|
||||
- Participate in feature flags or access control.
|
||||
- Link to related data (e.g., an XML template referencing a project tag).
|
||||
- Provide a unified navigation experience.
|
||||
- Be tested with standard tooling (Jest, Playwright).
|
||||
|
||||
Integration brings these tools into the dashboard shell with shared infrastructure while preserving all existing functionality.
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
Migration happens in three phases. Each phase produces a working state -- there is no "big bang" cutover.
|
||||
|
||||
### Phase 1: Embed (Temporary Bridge)
|
||||
|
||||
Wrap the existing HTML file in an `<iframe>` inside a dashboard page. This gives immediate navigation integration with zero rewrite.
|
||||
|
||||
```tsx
|
||||
// src/modules/email-signature/pages/email-signature-page.tsx (Phase 1 only)
|
||||
|
||||
export function EmailSignaturePage() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-3.5rem)]">
|
||||
<iframe
|
||||
src="/legacy/emailsignature-config.html"
|
||||
className="w-full h-full border-0"
|
||||
title="Email Signature Configurator"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**When to use Phase 1:**
|
||||
- When a tool needs to appear in the sidebar immediately but rewrite resources are not available.
|
||||
- As a fallback during Phase 2 development (iframe stays live while React version is being built).
|
||||
|
||||
**Limitations:**
|
||||
- No theme synchronization (iframe has its own styles).
|
||||
- No shared state between iframe and parent.
|
||||
- No storage abstraction.
|
||||
- Content Security Policy may block certain CDN scripts.
|
||||
|
||||
**Phase 1 is not recommended as a long-term solution.** Move to Phase 2 as soon as practical.
|
||||
|
||||
### Phase 2: Extract
|
||||
|
||||
Pull JavaScript logic out of the HTML files into typed TypeScript hooks and utility functions. Build a React UI that replaces the HTML structure.
|
||||
|
||||
This is where the bulk of the work happens. The goal is functional parity with the original tool, running inside the React component tree with proper state management.
|
||||
|
||||
Detailed extraction plans for each tool are in the sections below.
|
||||
|
||||
### Phase 3: Normalize
|
||||
|
||||
With React UI in place, integrate with platform-level features:
|
||||
|
||||
- **Storage abstraction**: Save/load configurations through the shared storage layer instead of raw `localStorage`.
|
||||
- **Theming**: All colors respond to dark/light toggle and company accent.
|
||||
- **Tagging**: Link generated artifacts to project tags from the tag manager.
|
||||
- **Feature flags**: Gate experimental features behind flags.
|
||||
- **Company context**: Tool behavior adapts to the selected company (logo, colors, address, namespace).
|
||||
- **Cross-module linking**: An XML template can reference a project; a signature config can link to a company profile.
|
||||
|
||||
---
|
||||
|
||||
## Email Signature Generator -- Migration Plan
|
||||
|
||||
### Current State Analysis
|
||||
|
||||
The existing `emailsignature-config.html` contains:
|
||||
|
||||
1. **Data inputs**: prefix, name, title, phone (4 text fields).
|
||||
2. **Color picker system**: 7 color targets (prefix, name, title, address, phone, website, motto) with 4 swatch options each (Beletage brand palette).
|
||||
3. **Layout sliders**: 8 range inputs controlling pixel-level spacing (green line width, section spacing, logo spacing, title spacing, gutter alignment, icon-text spacing, icon vertical position, motto spacing).
|
||||
4. **Options**: 3 checkboxes (reply variant, super-reply variant, SVG images).
|
||||
5. **Signature HTML template builder**: `generateSignatureHTML(data)` function producing a table-based email signature.
|
||||
6. **Phone formatter**: Converts `07xxxxxxxx` to `+40 xxx xxx xxx` format.
|
||||
7. **Live preview**: Real-time DOM update on any input change.
|
||||
8. **Export**: Downloads the signature HTML as a file.
|
||||
9. **Zoom toggle**: 100%/200% preview scaling.
|
||||
10. **Collapsible sections**: Manual accordion implementation.
|
||||
|
||||
Everything is Beletage-specific: logo URL, address, website, motto, brand colors.
|
||||
|
||||
### Extraction Plan
|
||||
|
||||
#### Hook: `useSignatureBuilder`
|
||||
|
||||
Encapsulates the signature generation logic.
|
||||
|
||||
```
|
||||
Source: generateSignatureHTML() function (lines 280-361)
|
||||
Target: src/modules/email-signature/hooks/use-signature-builder.ts
|
||||
|
||||
Responsibilities:
|
||||
- Accept SignatureConfig object (all field values, colors, spacing, variant flags)
|
||||
- Return generated HTML string
|
||||
- Return structured SignatureData for preview rendering
|
||||
- Pure computation, no side effects
|
||||
|
||||
Interface:
|
||||
Input: SignatureConfig (typed object with all config fields)
|
||||
Output: { html: string; previewData: SignatureData }
|
||||
```
|
||||
|
||||
#### Hook: `useSignatureExport`
|
||||
|
||||
Handles file download and clipboard copy.
|
||||
|
||||
```
|
||||
Source: export button click handler (lines 441-450)
|
||||
Target: src/modules/email-signature/hooks/use-signature-export.ts
|
||||
|
||||
Responsibilities:
|
||||
- Generate Blob from HTML string
|
||||
- Trigger file download with appropriate filename
|
||||
- Copy HTML to clipboard
|
||||
- Filename includes company name and date
|
||||
|
||||
Interface:
|
||||
Input: { html: string; companySlug: string }
|
||||
Output: { downloadHtml: () => void; copyToClipboard: () => Promise<void> }
|
||||
```
|
||||
|
||||
#### Utility: `formatPhoneNumber`
|
||||
|
||||
```
|
||||
Source: phone formatting logic in updatePreview() (lines 365-372)
|
||||
Target: src/shared/utils/format-phone.ts
|
||||
|
||||
Responsibilities:
|
||||
- Accept raw phone string
|
||||
- Detect Romanian mobile format (07xxxxxxxx)
|
||||
- Return { display: string; tel: string } with formatted display and tel: link
|
||||
|
||||
This is a shared utility, not signature-specific.
|
||||
```
|
||||
|
||||
#### React UI Replacements
|
||||
|
||||
| Original | React Replacement |
|
||||
|---|---|
|
||||
| Text inputs with `document.getElementById` | Controlled `<Input>` components with React state |
|
||||
| Color swatch grid with DOM event delegation | `<ColorPicker>` component with `useState` |
|
||||
| Range inputs with manual value display | `<Slider>` (shadcn/ui) with value label |
|
||||
| Collapsible sections | `<Collapsible>` (shadcn/ui) |
|
||||
| Checkboxes | `<Switch>` or `<Checkbox>` (shadcn/ui) |
|
||||
| Live preview via `innerHTML` | React component rendering signature structure |
|
||||
| `alert()`/`confirm()` | `<AlertDialog>` (shadcn/ui) |
|
||||
| File download via DOM | `useSignatureExport` hook |
|
||||
|
||||
#### Generalization: Multi-Company Support
|
||||
|
||||
The current tool is hardcoded for Beletage. The React version must support all three companies.
|
||||
|
||||
```ts
|
||||
// src/config/companies.ts
|
||||
|
||||
export interface CompanyProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
accent: string; // hex color
|
||||
logo: {
|
||||
png: string;
|
||||
svg: string;
|
||||
};
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
county: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
mapsUrl: string;
|
||||
};
|
||||
website: string;
|
||||
motto: string;
|
||||
brandColors: Record<string, string>; // named palette
|
||||
signatureIcons: {
|
||||
greySlash: { png: string; svg: string };
|
||||
greenSlash: { png: string; svg: string };
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The company selector in the header drives `CompanyProfile` into context. The signature builder reads from this context to populate logo, address, website, motto, and available colors.
|
||||
|
||||
#### New: Storage Integration
|
||||
|
||||
Save and load signature configurations via the storage abstraction:
|
||||
|
||||
```ts
|
||||
// Signature configs are stored as:
|
||||
{
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string; // e.g., "Marius TARAU - Beletage"
|
||||
config: SignatureConfig; // all field values
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Users can save multiple configs (one per person/company combo), load previous configs, and delete old ones.
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
src/modules/email-signature/
|
||||
pages/
|
||||
email-signature-page.tsx -- Main page component
|
||||
components/
|
||||
signature-form.tsx -- Config form (inputs, colors, sliders)
|
||||
signature-preview.tsx -- Live preview panel
|
||||
signature-color-picker.tsx -- Color swatch selector
|
||||
saved-configs-list.tsx -- List of saved configurations
|
||||
hooks/
|
||||
use-signature-builder.ts -- HTML generation logic
|
||||
use-signature-export.ts -- Download/copy logic
|
||||
use-signature-config.ts -- State management for all config fields
|
||||
types.ts -- SignatureConfig, SignatureData interfaces
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Word XML Generators -- Migration Plan (Consolidate 3 into 1)
|
||||
|
||||
### Current State Analysis
|
||||
|
||||
Three separate HTML files with increasing complexity:
|
||||
|
||||
**Basic** (`word-xml-generator-basic.html`):
|
||||
- Namespace URI, root element name, field list (textarea).
|
||||
- Generates XML with one element per field.
|
||||
- Generates XPath list.
|
||||
- Copy to clipboard, download XML, demo fill.
|
||||
- Field name sanitization (spaces to underscores, invalid chars removed, dedup).
|
||||
|
||||
**Medium** (`word-xml-generator-medium.html`):
|
||||
- Same as basic, but generates 6 variants per field: base, Short, Upper, Lower, Initials, First.
|
||||
- `initials()` helper function.
|
||||
|
||||
**Advanced** (`word-xml-generator-advanced.html`):
|
||||
- Category-based organization (Beneficiar, Proiect, Suprafete, Meta) with default presets.
|
||||
- Per-category namespace (base namespace + `/CategoryName`).
|
||||
- Per-category root element (`CategoryNameData`).
|
||||
- Simple/Advanced mode toggle (advanced adds the 6 variants).
|
||||
- POT/CUT metric computation for "Suprafete" category.
|
||||
- localStorage persistence for category data.
|
||||
- Category management (add, delete, reset to preset, clear).
|
||||
- Single-category XML download.
|
||||
- ZIP export of all categories via JSZip.
|
||||
- Pill-based category selector UI.
|
||||
|
||||
### Consolidation Strategy
|
||||
|
||||
The three tools become a single React module with a complexity toggle:
|
||||
|
||||
| Mode | Equivalent to | Behavior |
|
||||
|---|---|---|
|
||||
| **Simplu** | basic | One element per field, no variants |
|
||||
| **Avansat** | advanced | Categories, variants, POT/CUT, ZIP |
|
||||
|
||||
The medium version is subsumed by the advanced mode -- it was an intermediate step, not a distinct use case.
|
||||
|
||||
### Extraction Plan
|
||||
|
||||
#### Hook: `useXmlGenerator`
|
||||
|
||||
Core XML generation logic.
|
||||
|
||||
```
|
||||
Source: generateXML() from basic, generateCategory() from advanced
|
||||
Target: src/modules/xml-generator/hooks/use-xml-generator.ts
|
||||
|
||||
Responsibilities:
|
||||
- Accept field list, namespace, root element name, mode (simple/advanced)
|
||||
- Generate XML string for a Custom XML Part
|
||||
- Generate XPath listing
|
||||
- Handle variant generation (Short, Upper, Lower, Initials, First)
|
||||
- Handle metric fields (POT/CUT) when category is surface-related
|
||||
|
||||
Interface:
|
||||
Input: XmlGeneratorInput { fields: string[]; namespace: string; rootElement: string; mode: 'simple' | 'advanced'; computeMetrics: boolean; categoryName?: string }
|
||||
Output: XmlGeneratorResult { xml: string; xpaths: string; fieldCount: number }
|
||||
```
|
||||
|
||||
#### Hook: `useCategoryManager`
|
||||
|
||||
Category CRUD and persistence.
|
||||
|
||||
```
|
||||
Source: initCategories(), switchCategory(), addCategoryPrompt(), etc. from advanced
|
||||
Target: src/modules/xml-generator/hooks/use-category-manager.ts
|
||||
|
||||
Responsibilities:
|
||||
- Manage list of categories with their field text
|
||||
- Track active category
|
||||
- Provide CRUD operations (add, delete, rename, reset to preset, clear)
|
||||
- Persist to storage abstraction (not raw localStorage)
|
||||
- Load default presets on first use
|
||||
|
||||
Interface:
|
||||
Input: StorageAdapter
|
||||
Output: { categories: Category[]; activeCategory: string; addCategory, deleteCategory, ... }
|
||||
```
|
||||
|
||||
#### Pure Function: `sanitizeXmlName`
|
||||
|
||||
```
|
||||
Source: sanitizeName() / sanitizeXmlName() (present in all three files)
|
||||
Target: src/modules/xml-generator/utils/sanitize-xml-name.ts
|
||||
|
||||
Responsibilities:
|
||||
- Trim whitespace
|
||||
- Replace spaces with underscores
|
||||
- Remove invalid XML name characters
|
||||
- Ensure name starts with letter or underscore
|
||||
- Return null for empty input
|
||||
|
||||
Easily unit-tested in isolation.
|
||||
```
|
||||
|
||||
#### Pure Function: `generateFieldVariants`
|
||||
|
||||
```
|
||||
Source: variant generation logic in medium and advanced
|
||||
Target: src/modules/xml-generator/utils/generate-field-variants.ts
|
||||
|
||||
Responsibilities:
|
||||
- Given a base field name, return array of variant names
|
||||
- Variants: base, baseShort, baseUpper, baseLower, baseInitials, baseFirst
|
||||
- Deduplication against a provided Set of used names
|
||||
```
|
||||
|
||||
#### Pure Function: `generateXpaths`
|
||||
|
||||
```
|
||||
Source: XPath string building in all three files
|
||||
Target: src/modules/xml-generator/utils/generate-xpaths.ts
|
||||
|
||||
Responsibilities:
|
||||
- Given root element and field list (with variants), produce formatted XPath listing
|
||||
- Include namespace and root info in header
|
||||
```
|
||||
|
||||
#### Service: `zipExportService`
|
||||
|
||||
```
|
||||
Source: downloadZipAll() from advanced
|
||||
Target: src/modules/xml-generator/services/zip-export-service.ts
|
||||
|
||||
Responsibilities:
|
||||
- Accept map of { filename: xmlContent }
|
||||
- Use JSZip to create archive
|
||||
- Return Blob for download
|
||||
|
||||
Dependency: jszip (npm package, replaces CDN script tag)
|
||||
```
|
||||
|
||||
### React UI Replacements
|
||||
|
||||
| Original | React Replacement |
|
||||
|---|---|
|
||||
| Textarea for field list | Controlled `<Textarea>` with React state |
|
||||
| Pill-based category selector | `<Tabs>` (shadcn/ui) or custom pill component |
|
||||
| Simple/Advanced pill toggle | `<Tabs>` with two items |
|
||||
| `prompt()` for new category name | `<Dialog>` with `<Input>` |
|
||||
| `confirm()` for deletion | `<AlertDialog>` |
|
||||
| `alert()` for validation | Toast notification (shadcn/ui `sonner`) |
|
||||
| `<pre>` for XML output | `<pre>` with syntax highlighting (optional) + copy button |
|
||||
| Direct `localStorage` | Storage abstraction via hook |
|
||||
| JSZip CDN | `jszip` npm package |
|
||||
|
||||
### New Features in React Version
|
||||
|
||||
1. **Template presets per company**: Each company can have its own default categories and fields. Driven by company context.
|
||||
2. **Save/load configurations**: Named configurations stored via storage abstraction. Users can maintain multiple XML schemas.
|
||||
3. **Project tag linking**: When generating XML for a specific project, link the configuration to a project tag from the tag manager.
|
||||
4. **Copy individual XPaths**: Click-to-copy on each XPath line, not just the whole block.
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
src/modules/xml-generator/
|
||||
pages/
|
||||
xml-generator-page.tsx -- Main page component
|
||||
components/
|
||||
xml-generator-form.tsx -- Namespace, root, mode controls
|
||||
category-manager.tsx -- Category tabs/pills + CRUD
|
||||
field-editor.tsx -- Textarea for field list
|
||||
xml-preview.tsx -- XML output with copy/download
|
||||
xpath-preview.tsx -- XPath output with copy
|
||||
saved-configs-list.tsx -- Saved configuration browser
|
||||
hooks/
|
||||
use-xml-generator.ts -- XML generation logic
|
||||
use-category-manager.ts -- Category state management
|
||||
use-xml-export.ts -- Download/ZIP export
|
||||
services/
|
||||
zip-export-service.ts -- JSZip wrapper
|
||||
utils/
|
||||
sanitize-xml-name.ts -- Field name sanitization
|
||||
generate-field-variants.ts -- Variant name generation
|
||||
generate-xpaths.ts -- XPath string builder
|
||||
data/
|
||||
default-presets.ts -- Default category presets
|
||||
types.ts -- Category, XmlGeneratorInput, etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## General Extraction Patterns
|
||||
|
||||
### DOM Manipulation to React State
|
||||
|
||||
| HTML/JS Pattern | React Equivalent |
|
||||
|---|---|
|
||||
| `document.getElementById('x').value` | `const [x, setX] = useState('')` + controlled input |
|
||||
| `element.innerHTML = html` | JSX return with variables |
|
||||
| `element.textContent = text` | `{text}` in JSX |
|
||||
| `element.classList.toggle('active')` | Conditional className: `cn('pill', isActive && 'active')` |
|
||||
| `element.style.backgroundColor = color` | `style={{ backgroundColor: color }}` or Tailwind class |
|
||||
| `element.addEventListener('input', handler)` | `onChange={handler}` on element |
|
||||
| `document.createElement('div')` | JSX element or `.map()` rendering |
|
||||
| `parent.appendChild(child)` | Render in JSX, controlled by state array |
|
||||
| `element.disabled = true` | `disabled={condition}` prop |
|
||||
|
||||
### Refs
|
||||
|
||||
Use `useRef` only when React state is insufficient:
|
||||
- Measuring DOM element dimensions.
|
||||
- Integrating with third-party DOM libraries.
|
||||
- Storing mutable values that should not trigger re-render.
|
||||
|
||||
Do not use `useRef` as a replacement for `document.getElementById`. That pattern belongs in controlled component state.
|
||||
|
||||
### Storage
|
||||
|
||||
| HTML Pattern | React Equivalent |
|
||||
|---|---|
|
||||
| `localStorage.getItem('key')` | `storageAdapter.get<T>('key')` |
|
||||
| `localStorage.setItem('key', JSON.stringify(data))` | `storageAdapter.set('key', data)` |
|
||||
| `JSON.parse(localStorage.getItem('key'))` | `storageAdapter.get<T>('key')` (typed, handles parse errors) |
|
||||
|
||||
The storage abstraction (`src/core/storage/`) wraps localStorage with:
|
||||
- Typed get/set.
|
||||
- JSON serialization.
|
||||
- Error handling (quota exceeded, parse failures).
|
||||
- Key namespacing by module.
|
||||
- Future: swap backend to IndexedDB or API without changing module code.
|
||||
|
||||
### Styling
|
||||
|
||||
| HTML Pattern | React Equivalent |
|
||||
|---|---|
|
||||
| Inline `style="color: #22B5AB"` | `className="text-teal-500"` or CSS variable |
|
||||
| Tailwind CDN classes | Same Tailwind classes (compiled at build time, not CDN) |
|
||||
| Raw CSS in `<style>` block | Tailwind utilities in JSX, or `globals.css` for truly global styles |
|
||||
| CSS custom properties | Keep if needed for dynamic values (e.g., accent color) |
|
||||
| `linear-gradient(135deg, #38bdf8, #6366f1)` | Tailwind `bg-gradient-to-br from-sky-400 to-indigo-500` or define in theme |
|
||||
|
||||
### Dependencies
|
||||
|
||||
| HTML Pattern | React Equivalent |
|
||||
|---|---|
|
||||
| `<script src="https://cdn.tailwindcss.com">` | Tailwind installed via npm, configured in `tailwind.config.ts` |
|
||||
| `<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/...">` | `npm install jszip` + `import JSZip from 'jszip'` |
|
||||
| Google Fonts CDN link | `next/font/google` in layout |
|
||||
|
||||
### Dialogs
|
||||
|
||||
| HTML Pattern | React Equivalent |
|
||||
|---|---|
|
||||
| `alert('Message')` | `toast('Message')` via sonner, or `<AlertDialog>` for important messages |
|
||||
| `confirm('Are you sure?')` | `<AlertDialog>` with confirm/cancel buttons |
|
||||
| `prompt('Enter name:')` | `<Dialog>` with `<Input>` and submit button |
|
||||
|
||||
---
|
||||
|
||||
## File Placement Conventions
|
||||
|
||||
```
|
||||
src/
|
||||
modules/
|
||||
email-signature/ -- Email signature module
|
||||
pages/ -- Route-level page components
|
||||
components/ -- Module-specific UI components
|
||||
hooks/ -- Module-specific hooks
|
||||
services/ -- Module-specific services (e.g., zip export)
|
||||
utils/ -- Module-specific pure functions
|
||||
data/ -- Static data, presets, defaults
|
||||
types.ts -- Module type definitions
|
||||
xml-generator/ -- XML generator module (consolidated)
|
||||
(same structure)
|
||||
|
||||
shared/
|
||||
components/
|
||||
ui/ -- shadcn/ui components
|
||||
utils/ -- Cross-module utilities (formatPhoneNumber, etc.)
|
||||
hooks/ -- Cross-module hooks
|
||||
|
||||
core/
|
||||
storage/ -- Storage abstraction
|
||||
i18n/
|
||||
labels.ts -- All UI text
|
||||
theme/ -- Theme configuration
|
||||
|
||||
config/
|
||||
companies.ts -- Company profiles
|
||||
modules.ts -- Module registry (drives sidebar)
|
||||
```
|
||||
|
||||
Module code never imports from another module directly. Shared functionality lives in `src/shared/` or `src/core/`.
|
||||
|
||||
---
|
||||
|
||||
## Testing Migrated Logic
|
||||
|
||||
### Unit Tests
|
||||
|
||||
All extracted pure functions and hooks must have unit tests.
|
||||
|
||||
**Pure functions** (sanitize, format, generate): straightforward input/output tests.
|
||||
|
||||
```ts
|
||||
// src/modules/xml-generator/utils/__tests__/sanitize-xml-name.test.ts
|
||||
|
||||
import { sanitizeXmlName } from '../sanitize-xml-name';
|
||||
|
||||
describe('sanitizeXmlName', () => {
|
||||
it('replaces spaces with underscores', () => {
|
||||
expect(sanitizeXmlName('Nume Client')).toBe('Nume_Client');
|
||||
});
|
||||
|
||||
it('removes invalid XML characters', () => {
|
||||
expect(sanitizeXmlName('Preț/m²')).toBe('Prem');
|
||||
});
|
||||
|
||||
it('prepends underscore if starts with digit', () => {
|
||||
expect(sanitizeXmlName('123Field')).toBe('_123Field');
|
||||
});
|
||||
|
||||
it('returns null for empty input', () => {
|
||||
expect(sanitizeXmlName('')).toBeNull();
|
||||
expect(sanitizeXmlName(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Hooks**: test with `@testing-library/react-hooks` or `renderHook` from `@testing-library/react`.
|
||||
|
||||
```ts
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useCategoryManager } from '../use-category-manager';
|
||||
|
||||
describe('useCategoryManager', () => {
|
||||
it('initializes with default presets', () => {
|
||||
const { result } = renderHook(() => useCategoryManager(mockStorage));
|
||||
expect(result.current.categories).toHaveLength(4);
|
||||
expect(result.current.activeCategory).toBe('Beneficiar');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test full page rendering with mocked storage. Verify that:
|
||||
- Form inputs update preview in real time.
|
||||
- Export produces valid HTML/XML.
|
||||
- Save/load round-trips correctly.
|
||||
|
||||
### Regression Testing
|
||||
|
||||
Before removing Phase 1 iframe, verify the React version produces identical output to the original HTML tool for a set of reference inputs. Store reference outputs as test fixtures.
|
||||
|
||||
For the email signature generator, compare generated HTML string character-by-character for known input configurations.
|
||||
|
||||
---
|
||||
|
||||
## Legacy Tool Preservation
|
||||
|
||||
Original HTML files are kept in the repository under their current paths for reference:
|
||||
|
||||
```
|
||||
emailsignature/emailsignature-config.html
|
||||
wordXMLgenerator/word-xml-generator-basic.html
|
||||
wordXMLgenerator/word-xml-generator-medium.html
|
||||
wordXMLgenerator/word-xml-generator-advanced.html
|
||||
```
|
||||
|
||||
These files are not served by the Next.js application in production. They remain in the repository as:
|
||||
- Reference implementation for migration accuracy.
|
||||
- Fallback if a React module has a blocking bug.
|
||||
- Historical documentation of original tool behavior.
|
||||
|
||||
Once Phase 3 is complete and the React versions are stable, legacy files can be moved to a `legacy/` directory or archived in a separate branch. Do not delete them until the React versions have been in production use for at least one release cycle.
|
||||
748
docs/guides/MODULE-DEVELOPMENT.md
Normal file
748
docs/guides/MODULE-DEVELOPMENT.md
Normal file
@@ -0,0 +1,748 @@
|
||||
# Module Development Guide
|
||||
|
||||
> Step-by-step guide for creating a new ArchiTools module. Read [MODULE-SYSTEM.md](/docs/architecture/MODULE-SYSTEM.md) and [FEATURE-FLAGS.md](/docs/architecture/FEATURE-FLAGS.md) first.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+, pnpm
|
||||
- Familiarity with the module system architecture and feature flag system
|
||||
- A clear idea of your module's purpose, category, and naming
|
||||
|
||||
---
|
||||
|
||||
## Walkthrough: Creating a New Module
|
||||
|
||||
This guide walks through creating a **"Registru Corespondenta"** (correspondence register) module as a concrete example. Adapt names and types to your use case.
|
||||
|
||||
---
|
||||
|
||||
### Step 1: Create the Module Directory
|
||||
|
||||
```bash
|
||||
mkdir -p src/modules/registru-corespondenta/{components,hooks,services}
|
||||
touch src/modules/registru-corespondenta/{types.ts,config.ts,index.ts}
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```
|
||||
src/modules/registru-corespondenta/
|
||||
components/
|
||||
hooks/
|
||||
services/
|
||||
types.ts
|
||||
config.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Define Types in `types.ts`
|
||||
|
||||
Define all TypeScript interfaces and types that are specific to this module. These types are internal to the module and must not be imported by other modules.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/types.ts
|
||||
|
||||
export interface Corespondenta {
|
||||
id: string;
|
||||
numar: string; // registration number
|
||||
data: string; // ISO date string
|
||||
expeditor: string; // sender
|
||||
destinatar: string; // recipient
|
||||
subiect: string; // subject
|
||||
tip: TipCorespondenta;
|
||||
status: StatusCorespondenta;
|
||||
fisiere: string[]; // attached file references
|
||||
note: string;
|
||||
creatLa: string; // ISO timestamp
|
||||
modificatLa: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export type TipCorespondenta = 'intrare' | 'iesire' | 'intern';
|
||||
|
||||
export type StatusCorespondenta = 'inregistrat' | 'in-lucru' | 'finalizat' | 'arhivat';
|
||||
|
||||
export interface CorrespondentaFilters {
|
||||
tip?: TipCorespondenta;
|
||||
status?: StatusCorespondenta;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
searchTerm?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
- Use Romanian names for domain types to match business terminology.
|
||||
- Keep types self-contained. If you need a shared type (e.g., `DateRange`), check `src/types/` first.
|
||||
- Export everything -- the barrel export in `index.ts` will control what is actually public.
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Create `config.ts` with ModuleConfig
|
||||
|
||||
This is the module's identity. Every field matters.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/config.ts
|
||||
|
||||
import type { ModuleConfig } from '@/types/modules';
|
||||
|
||||
export const registruCorrespondentaConfig: ModuleConfig = {
|
||||
id: 'registru-corespondenta',
|
||||
name: 'Registru Corespondenta',
|
||||
description: 'Evidenta si gestionarea corespondentei primite si expediate.',
|
||||
icon: 'mail',
|
||||
route: '/registru-corespondenta',
|
||||
category: 'operations',
|
||||
featureFlag: 'module.registru-corespondenta',
|
||||
visibility: 'all',
|
||||
version: '1.0.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'architools.registru-corespondenta',
|
||||
navOrder: 30,
|
||||
tags: ['corespondenta', 'registru', 'documente'],
|
||||
};
|
||||
```
|
||||
|
||||
**Checklist for config values:**
|
||||
- [ ] `id` is kebab-case and unique across all modules
|
||||
- [ ] `route` starts with `/` and does not collide with existing routes
|
||||
- [ ] `featureFlag` follows the `module.<id>` convention
|
||||
- [ ] `storageNamespace` follows the `architools.<id>` convention
|
||||
- [ ] `navOrder` does not conflict with other modules in the same category (check `src/config/modules.ts`)
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Implement the Storage Service
|
||||
|
||||
Services handle all data persistence. They use the platform's storage abstraction layer and scope all keys under the module's `storageNamespace`.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/services/corespondenta-storage.ts
|
||||
|
||||
import { StorageService } from '@/lib/storage';
|
||||
import type { Corespondenta, CorrespondentaFilters } from '../types';
|
||||
import { registruCorrespondentaConfig } from '../config';
|
||||
|
||||
const COLLECTION_KEY = 'entries';
|
||||
|
||||
const storage = new StorageService(registruCorrespondentaConfig.storageNamespace);
|
||||
|
||||
export const corespondentaStorage = {
|
||||
async getAll(): Promise<Corespondenta[]> {
|
||||
return storage.get<Corespondenta[]>(COLLECTION_KEY) ?? [];
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<Corespondenta | undefined> {
|
||||
const all = await this.getAll();
|
||||
return all.find((item) => item.id === id);
|
||||
},
|
||||
|
||||
async save(item: Corespondenta): Promise<void> {
|
||||
const all = await this.getAll();
|
||||
const index = all.findIndex((existing) => existing.id === item.id);
|
||||
|
||||
if (index >= 0) {
|
||||
all[index] = { ...item, modificatLa: new Date().toISOString() };
|
||||
} else {
|
||||
all.push({
|
||||
...item,
|
||||
creatLa: new Date().toISOString(),
|
||||
modificatLa: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
await storage.set(COLLECTION_KEY, all);
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const all = await this.getAll();
|
||||
const filtered = all.filter((item) => item.id !== id);
|
||||
await storage.set(COLLECTION_KEY, filtered);
|
||||
},
|
||||
|
||||
async filter(filters: CorrespondentaFilters): Promise<Corespondenta[]> {
|
||||
let results = await this.getAll();
|
||||
|
||||
if (filters.tip) {
|
||||
results = results.filter((item) => item.tip === filters.tip);
|
||||
}
|
||||
if (filters.status) {
|
||||
results = results.filter((item) => item.status === filters.status);
|
||||
}
|
||||
if (filters.searchTerm) {
|
||||
const term = filters.searchTerm.toLowerCase();
|
||||
results = results.filter(
|
||||
(item) =>
|
||||
item.subiect.toLowerCase().includes(term) ||
|
||||
item.expeditor.toLowerCase().includes(term) ||
|
||||
item.destinatar.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
if (filters.startDate) {
|
||||
results = results.filter((item) => item.data >= filters.startDate!);
|
||||
}
|
||||
if (filters.endDate) {
|
||||
results = results.filter((item) => item.data <= filters.endDate!);
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getNextNumber(tip: Corespondenta['tip']): Promise<string> {
|
||||
const all = await this.getAll();
|
||||
const ofType = all.filter((item) => item.tip === tip);
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = tip === 'intrare' ? 'I' : tip === 'iesire' ? 'E' : 'N';
|
||||
const nextSeq = ofType.length + 1;
|
||||
return `${prefix}-${year}-${String(nextSeq).padStart(4, '0')}`;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Never call `localStorage` or `IndexedDB` directly. Use `StorageService` from `@/lib/storage`.
|
||||
- Always scope storage through the `storageNamespace` from config.
|
||||
- Keep services pure data logic -- no React, no UI concerns.
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Write Hooks for Business Logic
|
||||
|
||||
Hooks bridge services and components. They encapsulate state management, loading states, error handling, and business rules.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/hooks/useCorespondenta.ts
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { corespondentaStorage } from '../services/corespondenta-storage';
|
||||
import type { Corespondenta, CorrespondentaFilters } from '../types';
|
||||
|
||||
export function useCorespondenta(filters?: CorrespondentaFilters) {
|
||||
const [items, setItems] = useState<Corespondenta[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = filters
|
||||
? await corespondentaStorage.filter(filters)
|
||||
: await corespondentaStorage.getAll();
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Eroare la incarcarea datelor'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
}, [loadItems]);
|
||||
|
||||
const saveItem = useCallback(async (item: Corespondenta) => {
|
||||
await corespondentaStorage.save(item);
|
||||
await loadItems();
|
||||
}, [loadItems]);
|
||||
|
||||
const removeItem = useCallback(async (id: string) => {
|
||||
await corespondentaStorage.remove(id);
|
||||
await loadItems();
|
||||
}, [loadItems]);
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading,
|
||||
error,
|
||||
saveItem,
|
||||
removeItem,
|
||||
refresh: loadItems,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/hooks/useCorrespondentaForm.ts
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { corespondentaStorage } from '../services/corespondenta-storage';
|
||||
import type { Corespondenta, TipCorespondenta } from '../types';
|
||||
import { generateId } from '@/lib/utils';
|
||||
|
||||
export function useCorrespondentaForm(tip: TipCorespondenta) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const createNew = useCallback(async (
|
||||
data: Omit<Corespondenta, 'id' | 'numar' | 'creatLa' | 'modificatLa'>
|
||||
): Promise<Corespondenta> => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const numar = await corespondentaStorage.getNextNumber(tip);
|
||||
const item: Corespondenta = {
|
||||
...data,
|
||||
id: generateId(),
|
||||
numar,
|
||||
creatLa: new Date().toISOString(),
|
||||
modificatLa: new Date().toISOString(),
|
||||
};
|
||||
await corespondentaStorage.save(item);
|
||||
return item;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [tip]);
|
||||
|
||||
return { createNew, isSaving };
|
||||
}
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
- Hooks must be in the `hooks/` directory.
|
||||
- Hooks must not import from other modules.
|
||||
- Hooks must not contain JSX or rendering logic.
|
||||
- Keep hooks focused -- one hook per concern (list, form, export, etc.).
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Build Components
|
||||
|
||||
Components live in `components/` and compose hooks + shared UI components.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/components/CorrespondentaList.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useCorespondenta } from '../hooks/useCorespondenta';
|
||||
import type { CorrespondentaFilters } from '../types';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface Props {
|
||||
filters?: CorrespondentaFilters;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
inregistrat: 'bg-blue-100 text-blue-800',
|
||||
'in-lucru': 'bg-yellow-100 text-yellow-800',
|
||||
finalizat: 'bg-green-100 text-green-800',
|
||||
arhivat: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export function CorrespondentaList({ filters, onSelect }: Props) {
|
||||
const { items, isLoading, error } = useCorespondenta(filters);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
Eroare: {error.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nu exista inregistrari.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nr.</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
<TableHead>Expeditor</TableHead>
|
||||
<TableHead>Subiect</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<TableCell className="font-mono">{item.numar}</TableCell>
|
||||
<TableCell>{new Date(item.data).toLocaleDateString('ro-RO')}</TableCell>
|
||||
<TableCell>{item.expeditor}</TableCell>
|
||||
<TableCell>{item.subiect}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={STATUS_COLORS[item.status]}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The root component ties everything together and serves as the module's entry point:
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/components/RegistruCorrespondentaModule.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CorrespondentaList } from './CorrespondentaList';
|
||||
import type { CorrespondentaFilters } from '../types';
|
||||
|
||||
export default function RegistruCorrespondentaModule() {
|
||||
const [filters, setFilters] = useState<CorrespondentaFilters>({});
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Registru Corespondenta</h1>
|
||||
{/* Add "Adauga" button here */}
|
||||
</div>
|
||||
|
||||
{/* Filter bar component here */}
|
||||
|
||||
<CorrespondentaList filters={filters} onSelect={setSelectedId} />
|
||||
|
||||
{/* Detail/edit panel here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- The root component must be a `default` export (required by `React.lazy`).
|
||||
- Use shared UI components from `@/components/ui/` (shadcn/ui).
|
||||
- Do not import components from other modules.
|
||||
- Mark all client-side components with `'use client'`.
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Create the Barrel Export in `index.ts`
|
||||
|
||||
The barrel export defines the module's public API. The rest of the system may only import from this file.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/index.ts
|
||||
|
||||
export { registruCorrespondentaConfig as config } from './config';
|
||||
export { default } from './components/RegistruCorrespondentaModule';
|
||||
```
|
||||
|
||||
**Important:** The default export must be the root component. This is what `React.lazy(() => import('@/modules/registru-corespondenta'))` will resolve to.
|
||||
|
||||
---
|
||||
|
||||
### Step 8: Register the Module in `src/config/modules.ts`
|
||||
|
||||
```typescript
|
||||
// src/config/modules.ts
|
||||
|
||||
import { registruCorrespondentaConfig } from '@/modules/registru-corespondenta/config';
|
||||
// ... other imports
|
||||
|
||||
const moduleConfigs: ModuleConfig[] = [
|
||||
// ... existing modules
|
||||
registruCorrespondentaConfig,
|
||||
];
|
||||
```
|
||||
|
||||
Then add the lazy component entry in the module loader:
|
||||
|
||||
```typescript
|
||||
// src/lib/module-loader.ts
|
||||
|
||||
const lazyComponents: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
|
||||
// ... existing entries
|
||||
'registru-corespondenta': React.lazy(() => import('@/modules/registru-corespondenta')),
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 9: Add the Feature Flag in `src/config/flags.ts`
|
||||
|
||||
```typescript
|
||||
// src/config/flags.ts -- add to the defaultFlags array
|
||||
|
||||
{
|
||||
key: 'module.registru-corespondenta',
|
||||
enabled: true,
|
||||
label: 'Registru Corespondenta',
|
||||
description: 'Activeaza modulul de evidenta a corespondentei.',
|
||||
category: 'module',
|
||||
overridable: true,
|
||||
},
|
||||
```
|
||||
|
||||
The `key` must exactly match the `featureFlag` value in your module's `config.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Step 10: Add the Route
|
||||
|
||||
Create the Next.js App Router page for your module.
|
||||
|
||||
```typescript
|
||||
// src/app/(modules)/registru-corespondenta/page.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { ModuleLoader } from '@/lib/module-loader';
|
||||
import { moduleRegistry } from '@/config/modules';
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function RegistruCorrespondentaPage() {
|
||||
const config = moduleRegistry.get('registru-corespondenta');
|
||||
|
||||
if (!config) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const enabled = useFeatureFlag(config.featureFlag);
|
||||
|
||||
if (!enabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <ModuleLoader config={config} />;
|
||||
}
|
||||
```
|
||||
|
||||
The route path must match the `route` field in your module config (minus the leading `/`).
|
||||
|
||||
---
|
||||
|
||||
### Step 11: Test with Flag On/Off
|
||||
|
||||
**Flag enabled (default):**
|
||||
|
||||
1. Start the dev server: `pnpm dev`
|
||||
2. Verify the module appears in the sidebar under its category.
|
||||
3. Click the sidebar link. Verify the module loads and displays correctly.
|
||||
4. Open the browser Network tab. Confirm a separate chunk is fetched for the module.
|
||||
5. Test all CRUD operations. Verify localStorage keys are prefixed with `architools.registru-corespondenta`.
|
||||
|
||||
**Flag disabled:**
|
||||
|
||||
1. In `src/config/flags.ts`, set `enabled: false` for `module.registru-corespondenta` (or use the env var `NEXT_PUBLIC_FLAG_MODULE_REGISTRU_CORESPONDENTA=false`).
|
||||
2. Verify the module disappears from the sidebar.
|
||||
3. Navigate directly to `/registru-corespondenta`. Verify you get a 404.
|
||||
4. Open the Network tab. Confirm no chunk for the module is fetched anywhere in the application.
|
||||
|
||||
**Error boundary:**
|
||||
|
||||
1. Temporarily add `throw new Error('test')` to the root component's render.
|
||||
2. Verify the error boundary catches it, displays the error UI, and the rest of the app remains functional.
|
||||
3. Click "Reincearca" and verify the module attempts to re-render.
|
||||
4. Remove the test error.
|
||||
|
||||
---
|
||||
|
||||
## Module Checklist
|
||||
|
||||
Use this checklist before submitting a PR for a new module.
|
||||
|
||||
### Structure
|
||||
- [ ] Module directory is at `src/modules/<module-id>/`
|
||||
- [ ] Directory contains: `components/`, `hooks/`, `services/`, `types.ts`, `config.ts`, `index.ts`
|
||||
- [ ] No files outside the module directory reference internal module files (only `index.ts` is imported externally)
|
||||
|
||||
### Configuration
|
||||
- [ ] `config.ts` exports a valid `ModuleConfig`
|
||||
- [ ] `id` is kebab-case and unique
|
||||
- [ ] `route` starts with `/`, is unique, and matches the App Router path
|
||||
- [ ] `featureFlag` matches the key in `flags.ts`
|
||||
- [ ] `storageNamespace` follows `architools.<id>` convention and is unique
|
||||
- [ ] `navOrder` does not conflict within its category
|
||||
|
||||
### Registration
|
||||
- [ ] Config imported and added to `moduleConfigs` in `src/config/modules.ts`
|
||||
- [ ] Lazy component added to `lazyComponents` in `src/lib/module-loader.ts`
|
||||
- [ ] Feature flag added to `defaultFlags` in `src/config/flags.ts`
|
||||
- [ ] Route page created at `src/app/(modules)/<route>/page.tsx`
|
||||
|
||||
### Code Quality
|
||||
- [ ] All types defined in `types.ts`
|
||||
- [ ] Storage access goes through `StorageService`, scoped to `storageNamespace`
|
||||
- [ ] No direct `localStorage`/`IndexedDB` calls
|
||||
- [ ] No imports from other modules (`@/modules/*` other than own)
|
||||
- [ ] Root component is a `default` export
|
||||
- [ ] All client components have `'use client'` directive
|
||||
- [ ] Hooks contain no JSX
|
||||
|
||||
### Testing
|
||||
- [ ] Module loads correctly with flag enabled
|
||||
- [ ] Module is completely absent (no chunk, no nav item) with flag disabled
|
||||
- [ ] Direct URL navigation to disabled module returns 404
|
||||
- [ ] Error boundary catches thrown errors gracefully
|
||||
- [ ] Storage keys are correctly namespaced (inspect in DevTools > Application > Local Storage)
|
||||
- [ ] Module works after page refresh (data persists)
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### 1. Importing from another module
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
import { formatDeviz } from '@/modules/devize-generator/services/formatter';
|
||||
|
||||
// RIGHT: If you need shared logic, move it to src/lib/ or src/utils/
|
||||
import { formatCurrency } from '@/lib/formatters';
|
||||
```
|
||||
|
||||
Modules are isolated units. Cross-module imports create hidden coupling and break the ability to independently enable/disable modules.
|
||||
|
||||
### 2. Bypassing the storage abstraction
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
localStorage.setItem('registru-corespondenta.entries', JSON.stringify(data));
|
||||
|
||||
// RIGHT
|
||||
const storage = new StorageService(config.storageNamespace);
|
||||
await storage.set('entries', data);
|
||||
```
|
||||
|
||||
Direct storage calls bypass namespace isolation, offline support, and future migration to IndexedDB or a remote backend.
|
||||
|
||||
### 3. Skipping the feature flag gate on the route
|
||||
|
||||
```typescript
|
||||
// WRONG: Module is always accessible via URL even when flag is disabled
|
||||
export default function MyModulePage() {
|
||||
return <ModuleLoader config={config} />;
|
||||
}
|
||||
|
||||
// RIGHT
|
||||
export default function MyModulePage() {
|
||||
const enabled = useFeatureFlag(config.featureFlag);
|
||||
if (!enabled) notFound();
|
||||
return <ModuleLoader config={config} />;
|
||||
}
|
||||
```
|
||||
|
||||
Without the flag check on the route, a user can access a disabled module by typing the URL directly.
|
||||
|
||||
### 4. Putting business logic in components
|
||||
|
||||
```typescript
|
||||
// WRONG: Component directly manages storage and transforms data
|
||||
function MyList() {
|
||||
const [items, setItems] = useState([]);
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem('...');
|
||||
const parsed = JSON.parse(raw);
|
||||
const filtered = parsed.filter(/* complex logic */);
|
||||
setItems(filtered);
|
||||
}, []);
|
||||
}
|
||||
|
||||
// RIGHT: Component delegates to a hook, which delegates to a service
|
||||
function MyList() {
|
||||
const { items, isLoading } = useCorespondenta(filters);
|
||||
}
|
||||
```
|
||||
|
||||
Business logic in components makes code untestable, unreusable, and hard to maintain. Follow the layers: Component -> Hook -> Service -> Storage.
|
||||
|
||||
### 5. Non-unique identifiers
|
||||
|
||||
```typescript
|
||||
// WRONG: Reusing an ID or namespace that another module already uses
|
||||
export const myConfig: ModuleConfig = {
|
||||
id: 'oferte', // already taken by the Oferte module
|
||||
storageNamespace: 'architools.oferte', // already taken
|
||||
};
|
||||
```
|
||||
|
||||
This will cause data corruption and routing conflicts. The registry validation catches duplicate IDs, but namespace collisions are only caught by code review.
|
||||
|
||||
### 6. Side effects in config.ts
|
||||
|
||||
```typescript
|
||||
// WRONG: config.ts should be pure data, no side effects
|
||||
console.log('Module loaded!');
|
||||
await initializeDatabase();
|
||||
|
||||
export const config: ModuleConfig = { ... };
|
||||
|
||||
// RIGHT: config.ts exports only a plain object
|
||||
export const config: ModuleConfig = { ... };
|
||||
```
|
||||
|
||||
Config files are imported at registration time for all modules, regardless of whether they are enabled. Side effects in config files run unconditionally and defeat the purpose of lazy loading.
|
||||
|
||||
### 7. Exporting internal types from index.ts
|
||||
|
||||
```typescript
|
||||
// WRONG: Exposing internal implementation types to the outside
|
||||
export { config } from './config';
|
||||
export { default } from './components/MyModule';
|
||||
export type { InternalState, PrivateHelper } from './types'; // leaking internals
|
||||
|
||||
// RIGHT: Only export what the platform needs
|
||||
export { config } from './config';
|
||||
export { default } from './components/MyModule';
|
||||
```
|
||||
|
||||
The barrel export should be minimal. The only consumers are the module registry (needs `config`) and the module loader (needs the default component). Internal types stay internal.
|
||||
|
||||
### 8. Monolithic root component
|
||||
|
||||
```typescript
|
||||
// WRONG: One 500-line component that does everything
|
||||
export default function MyModule() {
|
||||
// form logic, list logic, filter logic, modal logic, all inline
|
||||
}
|
||||
|
||||
// RIGHT: Compose from focused sub-components
|
||||
export default function MyModule() {
|
||||
return (
|
||||
<>
|
||||
<MyModuleHeader />
|
||||
<MyModuleFilters />
|
||||
<MyModuleList />
|
||||
<MyModuleDetailPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Break the module into focused components. The root component should primarily compose child components and manage top-level layout.
|
||||
864
docs/guides/TESTING-STRATEGY.md
Normal file
864
docs/guides/TESTING-STRATEGY.md
Normal file
@@ -0,0 +1,864 @@
|
||||
# Testing Strategy
|
||||
|
||||
> ArchiTools testing guide -- tools, conventions, coverage targets, and anti-patterns.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ArchiTools follows a testing pyramid: many fast unit tests at the base, fewer integration tests in the middle, and a small number of E2E tests at the top. Tests are co-located with the source code they test, not in a separate directory tree.
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ E2E │ Playwright -- critical user flows
|
||||
│ (few) │
|
||||
┌┴─────────┴┐
|
||||
│Integration │ Vitest -- module services with real storage
|
||||
│ (moderate) │
|
||||
┌┴────────────┴┐
|
||||
│ Component │ Vitest + React Testing Library
|
||||
│ (moderate) │
|
||||
┌┴──────────────┴┐
|
||||
│ Unit │ Vitest -- hooks, services, utilities
|
||||
│ (many) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Layer | Tool | Why |
|
||||
|---|---|---|
|
||||
| Unit | Vitest | Fast, native ESM, TypeScript-first, compatible with Next.js |
|
||||
| Component | Vitest + React Testing Library | Tests component behavior from the user's perspective |
|
||||
| Integration | Vitest | Same runner, but tests span module service + storage layers |
|
||||
| E2E | Playwright | Cross-browser, reliable, good Next.js support |
|
||||
|
||||
### Vitest Configuration
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/**/*.d.ts',
|
||||
'src/test/**',
|
||||
'src/types/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Test Setup
|
||||
|
||||
```typescript
|
||||
// src/test/setup.ts
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Mock localStorage for all tests
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { store = {}; },
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: (index: number) => Object.keys(store)[index] ?? null,
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Reset storage between tests
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Organization
|
||||
|
||||
Tests are co-located with the source files they test. No separate `__tests__` directories.
|
||||
|
||||
```
|
||||
src/modules/registratura/
|
||||
components/
|
||||
RegistryTable.tsx
|
||||
RegistryTable.test.tsx # component test
|
||||
RegistryForm.tsx
|
||||
RegistryForm.test.tsx # component test
|
||||
hooks/
|
||||
use-registry.ts
|
||||
use-registry.test.ts # hook unit test
|
||||
services/
|
||||
registry-service.ts
|
||||
registry-service.test.ts # service unit test
|
||||
registry-service.integration.test.ts # integration test
|
||||
types.ts
|
||||
config.ts
|
||||
|
||||
src/lib/tags/
|
||||
tag-service.ts
|
||||
tag-service.test.ts # unit test
|
||||
tag-service.integration.test.ts # integration test
|
||||
|
||||
src/lib/storage/
|
||||
storage-service.ts
|
||||
storage-service.test.ts # unit test
|
||||
|
||||
src/hooks/
|
||||
useFeatureFlag.ts
|
||||
useFeatureFlag.test.ts # unit test
|
||||
```
|
||||
|
||||
### Naming Convention
|
||||
|
||||
| File type | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Unit test | `[name].test.ts` | `registry-service.test.ts` |
|
||||
| Component test | `[Component].test.tsx` | `RegistryTable.test.tsx` |
|
||||
| Integration test | `[name].integration.test.ts` | `registry-service.integration.test.ts` |
|
||||
| E2E test | `[flow].spec.ts` (in `e2e/`) | `e2e/registratura.spec.ts` |
|
||||
| Test utility | `[name].ts` (in `src/test/`) | `src/test/factories.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Unit Tests
|
||||
|
||||
### What to Test
|
||||
|
||||
- **Services:** All CRUD operations, validation logic, error handling, storage key construction.
|
||||
- **Hooks:** State transitions, side effects, return value shape, error states.
|
||||
- **Utility functions:** Transformations, formatters, parsers, validators.
|
||||
- **Storage adapters:** Read/write/delete operations, namespace isolation.
|
||||
- **Feature flag evaluation:** Flag resolution logic, default values.
|
||||
|
||||
### What NOT to Test
|
||||
|
||||
- TypeScript types (they are compile-time only).
|
||||
- Static configuration objects (they have no logic).
|
||||
- Third-party library internals (test your usage, not their code).
|
||||
|
||||
### Service Test Example
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/services/registry-service.test.ts
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RegistryService } from './registry-service';
|
||||
import { createMockStorageService } from '@/test/mocks/storage';
|
||||
|
||||
describe('RegistryService', () => {
|
||||
let service: RegistryService;
|
||||
let storage: ReturnType<typeof createMockStorageService>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMockStorageService();
|
||||
service = new RegistryService(storage);
|
||||
});
|
||||
|
||||
describe('createEntry', () => {
|
||||
it('assigns an ID and timestamps on creation', async () => {
|
||||
const entry = await service.createEntry({
|
||||
title: 'Cerere CU',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
expect(entry.id).toBeDefined();
|
||||
expect(entry.createdAt).toBeDefined();
|
||||
expect(entry.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects entries with empty title', async () => {
|
||||
await expect(
|
||||
service.createEntry({ title: '', type: 'incoming', tagIds: [] })
|
||||
).rejects.toThrow('Title is required');
|
||||
});
|
||||
|
||||
it('stores the entry under the correct namespace', async () => {
|
||||
await service.createEntry({
|
||||
title: 'Cerere CU',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
expect(storage.setItem).toHaveBeenCalledWith(
|
||||
expect.stringContaining('architools.registratura'),
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntries', () => {
|
||||
it('returns an empty array when no entries exist', async () => {
|
||||
const entries = await service.getEntries();
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns entries sorted by creation date descending', async () => {
|
||||
await service.createEntry({ title: 'First', type: 'incoming', tagIds: [] });
|
||||
await service.createEntry({ title: 'Second', type: 'incoming', tagIds: [] });
|
||||
|
||||
const entries = await service.getEntries();
|
||||
expect(entries[0].title).toBe('Second');
|
||||
expect(entries[1].title).toBe('First');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Hook Test Example
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/hooks/use-registry.test.ts
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useRegistry } from './use-registry';
|
||||
import { TestProviders } from '@/test/providers';
|
||||
|
||||
describe('useRegistry', () => {
|
||||
it('loads entries on mount', async () => {
|
||||
const { result } = renderHook(() => useRegistry(), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds an entry and updates the list', async () => {
|
||||
const { result } = renderHook(() => useRegistry(), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.addEntry({
|
||||
title: 'Test Entry',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].title).toBe('Test Entry');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Tests
|
||||
|
||||
### Principles
|
||||
|
||||
- Test **behavior**, not implementation. Click buttons, fill inputs, assert on visible output.
|
||||
- Use `screen.getByRole`, `screen.getByText`, `screen.getByLabelText` -- prefer accessible queries.
|
||||
- Do not test CSS classes or DOM structure. Test what the user sees and can interact with.
|
||||
- Mock services at the hook level, not at the component level. Components should receive data through hooks.
|
||||
|
||||
### Component Test Example
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/components/RegistryTable.test.tsx
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { RegistryTable } from './RegistryTable';
|
||||
import { createTestEntry } from '@/test/factories';
|
||||
|
||||
describe('RegistryTable', () => {
|
||||
const mockEntries = [
|
||||
createTestEntry({ title: 'Cerere CU - Casa Popescu', type: 'incoming' }),
|
||||
createTestEntry({ title: 'Autorizatie construire', type: 'outgoing' }),
|
||||
];
|
||||
|
||||
it('renders all entries', () => {
|
||||
render(<RegistryTable entries={mockEntries} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Cerere CU - Casa Popescu')).toBeInTheDocument();
|
||||
expect(screen.getByText('Autorizatie construire')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onEdit when edit button is clicked', async () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<RegistryTable entries={mockEntries} onEdit={onEdit} onDelete={vi.fn()} />);
|
||||
|
||||
const editButtons = screen.getAllByRole('button', { name: /editeaza/i });
|
||||
await userEvent.click(editButtons[0]);
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(mockEntries[0].id);
|
||||
});
|
||||
|
||||
it('shows empty state when no entries exist', () => {
|
||||
render(<RegistryTable entries={[]} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/nu exista inregistrari/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters entries when tag filter is applied', async () => {
|
||||
render(
|
||||
<RegistryTable
|
||||
entries={mockEntries}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
activeTagIds={[mockEntries[0].tagIds[0]]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Cerere CU - Casa Popescu')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Autorizatie construire')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Tests
|
||||
|
||||
Integration tests verify that module services work correctly with the real storage adapter (localStorage mock from the test setup, not a hand-written mock).
|
||||
|
||||
### Module-Level Integration
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/services/registry-service.integration.test.ts
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RegistryService } from './registry-service';
|
||||
import { LocalStorageAdapter } from '@/lib/storage/local-storage-adapter';
|
||||
|
||||
describe('RegistryService (integration)', () => {
|
||||
let service: RegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
const storage = new LocalStorageAdapter('architools.registratura');
|
||||
service = new RegistryService(storage);
|
||||
});
|
||||
|
||||
it('persists and retrieves entries across service instances', async () => {
|
||||
await service.createEntry({
|
||||
title: 'Persistent Entry',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
// Create a new service instance pointing to the same storage
|
||||
const storage2 = new LocalStorageAdapter('architools.registratura');
|
||||
const service2 = new RegistryService(storage2);
|
||||
|
||||
const entries = await service2.getEntries();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].title).toBe('Persistent Entry');
|
||||
});
|
||||
|
||||
it('does not leak data across storage namespaces', async () => {
|
||||
await service.createEntry({
|
||||
title: 'Registry Entry',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
const otherStorage = new LocalStorageAdapter('architools.other-module');
|
||||
const keys = Object.keys(localStorage).filter((k) =>
|
||||
k.startsWith('architools.other-module')
|
||||
);
|
||||
expect(keys).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Cross-Module Integration
|
||||
|
||||
Test that the tagging system works correctly when used by a module.
|
||||
|
||||
```typescript
|
||||
// src/test/integration/tags-cross-module.integration.test.ts
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { TagService } from '@/lib/tags/tag-service';
|
||||
import { RegistryService } from '@/modules/registratura/services/registry-service';
|
||||
import { LocalStorageAdapter } from '@/lib/storage/local-storage-adapter';
|
||||
|
||||
describe('Tags cross-module integration', () => {
|
||||
let tagService: TagService;
|
||||
let registryService: RegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
tagService = new TagService(new LocalStorageAdapter('architools.tags'));
|
||||
registryService = new RegistryService(
|
||||
new LocalStorageAdapter('architools.registratura')
|
||||
);
|
||||
});
|
||||
|
||||
it('registry entries can reference tags from the tag service', async () => {
|
||||
const tag = await tagService.createTag({
|
||||
label: 'DTAC',
|
||||
category: 'phase',
|
||||
scope: 'global',
|
||||
color: '#14b8a6',
|
||||
});
|
||||
|
||||
const entry = await registryService.createEntry({
|
||||
title: 'Documentatie DTAC',
|
||||
type: 'incoming',
|
||||
tagIds: [tag.id],
|
||||
});
|
||||
|
||||
const resolvedTags = await tagService.getAllTags();
|
||||
const entryTags = resolvedTags.filter((t) => entry.tagIds.includes(t.id));
|
||||
|
||||
expect(entryTags).toHaveLength(1);
|
||||
expect(entryTags[0].label).toBe('DTAC');
|
||||
});
|
||||
|
||||
it('handles deleted tags gracefully in entity tag lists', async () => {
|
||||
const tag = await tagService.createTag({
|
||||
label: 'Temporary',
|
||||
category: 'custom',
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
await registryService.createEntry({
|
||||
title: 'Entry with temp tag',
|
||||
type: 'incoming',
|
||||
tagIds: [tag.id],
|
||||
});
|
||||
|
||||
await tagService.deleteTag(tag.id);
|
||||
|
||||
const allTags = await tagService.getAllTags();
|
||||
const entries = await registryService.getEntries();
|
||||
|
||||
// The entry still references the deleted tag ID
|
||||
expect(entries[0].tagIds).toContain(tag.id);
|
||||
// But the tag no longer exists
|
||||
expect(allTags.find((t) => t.id === tag.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E2E Tests
|
||||
|
||||
### Setup
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Critical Paths
|
||||
|
||||
E2E tests cover the flows that, if broken, would block daily use of ArchiTools.
|
||||
|
||||
| Flow | File | What it tests |
|
||||
|---|---|---|
|
||||
| Navigation | `e2e/navigation.spec.ts` | Sidebar renders enabled modules, disabled modules are absent, clicking navigates |
|
||||
| Registratura CRUD | `e2e/registratura.spec.ts` | Create, read, update, delete registry entries |
|
||||
| Tag management | `e2e/tag-manager.spec.ts` | Create tag, apply to entity, filter by tag |
|
||||
| Feature flags | `e2e/feature-flags.spec.ts` | Toggle flag off, verify module disappears from sidebar and route returns 404 |
|
||||
| Email Signature | `e2e/email-signature.spec.ts` | Generate signature, preview renders, copy to clipboard |
|
||||
| Data export/import | `e2e/data-export.spec.ts` | Export data as JSON, clear storage, import, verify data restored |
|
||||
|
||||
### E2E Test Example
|
||||
|
||||
```typescript
|
||||
// e2e/registratura.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Registratura', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/registratura');
|
||||
});
|
||||
|
||||
test('creates a new registry entry', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /adauga/i }).click();
|
||||
|
||||
await page.getByLabel(/titlu/i).fill('Cerere CU - Casa Test');
|
||||
await page.getByLabel(/tip/i).selectOption('incoming');
|
||||
await page.getByRole('button', { name: /salveaza/i }).click();
|
||||
|
||||
await expect(page.getByText('Cerere CU - Casa Test')).toBeVisible();
|
||||
});
|
||||
|
||||
test('edits an existing entry', async ({ page }) => {
|
||||
// Create an entry first
|
||||
await page.getByRole('button', { name: /adauga/i }).click();
|
||||
await page.getByLabel(/titlu/i).fill('Original Title');
|
||||
await page.getByLabel(/tip/i).selectOption('incoming');
|
||||
await page.getByRole('button', { name: /salveaza/i }).click();
|
||||
|
||||
// Edit it
|
||||
await page.getByRole('button', { name: /editeaza/i }).first().click();
|
||||
await page.getByLabel(/titlu/i).clear();
|
||||
await page.getByLabel(/titlu/i).fill('Updated Title');
|
||||
await page.getByRole('button', { name: /salveaza/i }).click();
|
||||
|
||||
await expect(page.getByText('Updated Title')).toBeVisible();
|
||||
await expect(page.getByText('Original Title')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('deletes an entry', async ({ page }) => {
|
||||
// Create an entry first
|
||||
await page.getByRole('button', { name: /adauga/i }).click();
|
||||
await page.getByLabel(/titlu/i).fill('To Be Deleted');
|
||||
await page.getByLabel(/tip/i).selectOption('incoming');
|
||||
await page.getByRole('button', { name: /salveaza/i }).click();
|
||||
|
||||
// Delete it
|
||||
await page.getByRole('button', { name: /sterge/i }).first().click();
|
||||
await page.getByRole('button', { name: /confirma/i }).click();
|
||||
|
||||
await expect(page.getByText('To Be Deleted')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Feature Flag E2E Test
|
||||
|
||||
```typescript
|
||||
// e2e/feature-flags.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Feature Flags', () => {
|
||||
test('disabled module is absent from sidebar', async ({ page }) => {
|
||||
// Set feature flag to disabled via localStorage before navigation
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'architools.flags',
|
||||
JSON.stringify({ 'module.registratura': false })
|
||||
);
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
const sidebar = page.getByRole('navigation');
|
||||
await expect(sidebar.getByText('Registratura')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('disabled module route returns 404 or redirects', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'architools.flags',
|
||||
JSON.stringify({ 'module.registratura': false })
|
||||
);
|
||||
});
|
||||
|
||||
await page.goto('/registratura');
|
||||
|
||||
// Should show a "module not found" or redirect to dashboard
|
||||
await expect(page.getByText(/nu a fost gasit/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Utilities
|
||||
|
||||
### Mock Storage Service
|
||||
|
||||
```typescript
|
||||
// src/test/mocks/storage.ts
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export function createMockStorageService() {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { store.set(key, value); }),
|
||||
removeItem: vi.fn((key: string) => { store.delete(key); }),
|
||||
clear: vi.fn(() => store.clear()),
|
||||
getAllKeys: vi.fn(() => Array.from(store.keys())),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Mock Feature Flag Provider
|
||||
|
||||
```typescript
|
||||
// src/test/mocks/feature-flags.ts
|
||||
|
||||
import React from 'react';
|
||||
import { FeatureFlagContext } from '@/lib/feature-flags/context';
|
||||
|
||||
interface MockFlagProviderProps {
|
||||
flags?: Record<string, boolean>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MockFeatureFlagProvider({
|
||||
flags = {},
|
||||
children,
|
||||
}: MockFlagProviderProps) {
|
||||
const allEnabled = new Proxy(flags, {
|
||||
get: (target, prop: string) => target[prop] ?? true,
|
||||
});
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider value={allEnabled}>
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Test Data Factories
|
||||
|
||||
```typescript
|
||||
// src/test/factories.ts
|
||||
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { Tag } from '@/types/tags';
|
||||
|
||||
export function createTestEntry(overrides: Partial<RegistryEntry> = {}): RegistryEntry {
|
||||
return {
|
||||
id: uuid(),
|
||||
title: 'Test Entry',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
visibility: 'all',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestTag(overrides: Partial<Tag> = {}): Tag {
|
||||
return {
|
||||
id: uuid(),
|
||||
label: 'Test Tag',
|
||||
category: 'custom',
|
||||
scope: 'global',
|
||||
color: '#6366f1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestContact(overrides: Partial<Contact> = {}): Contact {
|
||||
return {
|
||||
id: uuid(),
|
||||
name: 'Ion Popescu',
|
||||
email: 'ion@example.com',
|
||||
phone: '+40 712 345 678',
|
||||
tagIds: [],
|
||||
visibility: 'all',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Test Providers Wrapper
|
||||
|
||||
```typescript
|
||||
// src/test/providers.tsx
|
||||
|
||||
import React from 'react';
|
||||
import { MockFeatureFlagProvider } from './mocks/feature-flags';
|
||||
|
||||
interface TestProvidersProps {
|
||||
children: React.ReactNode;
|
||||
flags?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function TestProviders({ children, flags }: TestProvidersProps) {
|
||||
return (
|
||||
<MockFeatureFlagProvider flags={flags}>
|
||||
{children}
|
||||
</MockFeatureFlagProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage Targets
|
||||
|
||||
| Layer | Target | Rationale |
|
||||
|---|---|---|
|
||||
| Services | 90%+ | Services contain all business logic and data access. Bugs here corrupt data. |
|
||||
| Hooks | 80%+ | Hooks orchestrate service calls and manage state. Most logic is delegated to services. |
|
||||
| Components | 70%+ | Component tests cover interactive behavior. Layout and styling are not tested. |
|
||||
| Utilities | 95%+ | Pure functions with clear inputs/outputs. Easy to test exhaustively. |
|
||||
| Overall | 75%+ | Weighted average across all layers. |
|
||||
|
||||
Coverage is measured with V8 via Vitest and reported in CI. Coverage gates are advisory, not blocking, during the initial build-out phase. They become blocking once the codebase stabilizes.
|
||||
|
||||
---
|
||||
|
||||
## CI Integration
|
||||
|
||||
Tests run on every pull request. The pipeline:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml (or equivalent CI config)
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Unit + Component + Integration tests
|
||||
run: npx vitest run --coverage
|
||||
|
||||
- name: E2E tests
|
||||
run: npx playwright test
|
||||
|
||||
- name: Upload coverage
|
||||
run: # upload lcov report to coverage service
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- All tests must pass before a PR can be merged.
|
||||
- Coverage regressions (dropping below target) produce a warning, not a blocking failure.
|
||||
- E2E tests run against a production build (`npm run build && npm start`) in CI, not against the dev server.
|
||||
- Flaky tests are quarantined (moved to a `*.flaky.test.ts` suffix) and tracked for repair. They do not block the pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Testing Anti-Patterns
|
||||
|
||||
These patterns are explicitly avoided in ArchiTools tests.
|
||||
|
||||
### 1. Testing Implementation Details
|
||||
|
||||
**Bad:** Asserting on internal state, private methods, or specific DOM structure.
|
||||
|
||||
```typescript
|
||||
// BAD: testing internal state
|
||||
expect(component.state.isOpen).toBe(true);
|
||||
|
||||
// BAD: testing CSS classes
|
||||
expect(container.querySelector('.modal-active')).toBeTruthy();
|
||||
```
|
||||
|
||||
**Good:** Assert on what the user sees or what the API returns.
|
||||
|
||||
```typescript
|
||||
// GOOD: testing visible behavior
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
```
|
||||
|
||||
### 2. Snapshot Testing for Components
|
||||
|
||||
Snapshot tests are brittle and provide low signal. A single class name change breaks the snapshot, and reviewers rubber-stamp snapshot updates. Do not use `toMatchSnapshot()` or `toMatchInlineSnapshot()` for component output.
|
||||
|
||||
### 3. Mocking Everything
|
||||
|
||||
Over-mocking eliminates the value of the test. If a service test mocks the storage adapter, the formatter, and the validator, it is testing nothing but the function's wiring.
|
||||
|
||||
**Rule:** Mock at the boundary. For service tests, mock storage. For hook tests, mock the service. For component tests, mock the hook. One layer deep, no more.
|
||||
|
||||
### 4. Testing the Framework
|
||||
|
||||
Do not test that React renders a component, that `useState` works, or that `useEffect` fires. Test your logic, not React's.
|
||||
|
||||
```typescript
|
||||
// BAD: testing that React works
|
||||
it('renders without crashing', () => {
|
||||
render(<MyComponent />);
|
||||
});
|
||||
```
|
||||
|
||||
This test passes for an empty `<div>` and catches nothing useful. Test specific behavior instead.
|
||||
|
||||
### 5. Coupling Tests to Data Order
|
||||
|
||||
Tests that depend on array order without explicitly sorting are fragile. If the service returns entries sorted by date and the test asserts on `entries[0].title`, it will break when a second entry has the same timestamp.
|
||||
|
||||
**Rule:** Either sort explicitly in the test or use `expect.arrayContaining` / `toContainEqual`.
|
||||
|
||||
### 6. Not Cleaning Up State
|
||||
|
||||
Tests that write to localStorage (or any shared state) without clearing it in `beforeEach` will produce order-dependent failures. The test setup file clears localStorage globally, but custom state (e.g., module-level caches) must be reset in the test's own `beforeEach`.
|
||||
|
||||
### 7. Giant E2E Tests
|
||||
|
||||
A single E2E test that creates 10 entities, edits 5, deletes 3, and checks a report is slow, fragile, and hard to debug. Keep E2E tests focused on one flow. Use `test.beforeEach` to set up preconditions via API/localStorage rather than through the UI.
|
||||
|
||||
### 8. Ignoring Async Behavior
|
||||
|
||||
```typescript
|
||||
// BAD: not waiting for async updates
|
||||
const { result } = renderHook(() => useRegistry());
|
||||
expect(result.current.entries).toHaveLength(1); // may be empty -- hook hasn't loaded yet
|
||||
|
||||
// GOOD: wait for the async operation
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
```
|
||||
586
docs/guides/UI-DESIGN-SYSTEM.md
Normal file
586
docs/guides/UI-DESIGN-SYSTEM.md
Normal file
@@ -0,0 +1,586 @@
|
||||
# UI Design System
|
||||
|
||||
ArchiTools internal design system reference. All UI decisions flow from this document.
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
ArchiTools serves architecture and engineering professionals at Beletage SRL, Urban Switch SRL, and Studii de Teren SRL. The interface must reflect the discipline of the work itself: precise, structured, technically grounded.
|
||||
|
||||
**Guiding principles:**
|
||||
|
||||
- **Professional over playful.** No rounded bubbly elements, no bright consumer gradients, no emoji-heavy interfaces. The aesthetic is closer to a CAD toolbar than a social media dashboard.
|
||||
- **Information-dense but not cluttered.** Architecture professionals need data visible at a glance. Favor card-based layouts with clear hierarchy over sparse minimalist voids.
|
||||
- **Technical confidence.** Use monospaced fonts for data fields, structured grids for layout, and precise spacing. The UI should feel like a well-organized technical drawing.
|
||||
- **Consistent across tools.** Every module (email signatures, XML generators, future tools) must feel like part of the same platform, not a collection of standalone pages.
|
||||
- **Dark and light with equal quality.** Both themes are first-class. The dark theme is the default (matching existing tool aesthetics). The light theme must be equally polished.
|
||||
|
||||
---
|
||||
|
||||
## Color System
|
||||
|
||||
### Brand Colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| `brand-teal` | `#22B5AB` | Primary accent. Beletage brand teal. Used for active states, primary buttons, links, focus rings. |
|
||||
| `brand-teal-light` | `#2DD4BF` | Hover state for teal elements. |
|
||||
| `brand-teal-dark` | `#14978F` | Pressed/active state. |
|
||||
|
||||
Each company in the group may define an override accent color. The teal serves as the platform default and Beletage-specific accent.
|
||||
|
||||
| Company | Accent | Usage Context |
|
||||
|---|---|---|
|
||||
| Beletage SRL | `#22B5AB` (teal) | Architecture projects |
|
||||
| Urban Switch SRL | TBD | Urban planning projects |
|
||||
| Studii de Teren SRL | TBD | Land survey projects |
|
||||
|
||||
The company selector in the header drives the active accent color via a CSS custom property (`--accent`).
|
||||
|
||||
### Slate Backgrounds
|
||||
|
||||
Derived from Tailwind's `slate` scale. These form the structural palette for both themes.
|
||||
|
||||
**Dark theme (default):**
|
||||
|
||||
| Token | Tailwind Class | Hex | Usage |
|
||||
|---|---|---|---|
|
||||
| `bg-app` | `bg-slate-950` | `#020617` | Application background |
|
||||
| `bg-card` | `bg-slate-900` | `#0f172a` | Card surfaces |
|
||||
| `bg-card-elevated` | `bg-slate-800` | `#1e293b` | Elevated cards, dropdowns, popovers |
|
||||
| `bg-input` | `bg-slate-950` | `#020617` | Input field backgrounds |
|
||||
| `border-default` | `border-slate-700` | `#334155` | Card borders, dividers |
|
||||
| `border-subtle` | `border-slate-800` | `#1e293b` | Subtle separators |
|
||||
| `text-primary` | `text-slate-100` | `#f1f5f9` | Primary text |
|
||||
| `text-secondary` | `text-slate-400` | `#94a3b8` | Secondary text, labels |
|
||||
| `text-muted` | `text-slate-500` | `#64748b` | Disabled text, placeholders |
|
||||
|
||||
**Light theme:**
|
||||
|
||||
| Token | Tailwind Class | Hex | Usage |
|
||||
|---|---|---|---|
|
||||
| `bg-app` | `bg-slate-50` | `#f8fafc` | Application background |
|
||||
| `bg-card` | `bg-white` | `#ffffff` | Card surfaces |
|
||||
| `bg-card-elevated` | `bg-slate-50` | `#f8fafc` | Elevated cards |
|
||||
| `bg-input` | `bg-white` | `#ffffff` | Input field backgrounds |
|
||||
| `border-default` | `border-slate-200` | `#e2e8f0` | Card borders, dividers |
|
||||
| `border-subtle` | `border-slate-100` | `#f1f5f9` | Subtle separators |
|
||||
| `text-primary` | `text-slate-900` | `#0f172a` | Primary text |
|
||||
| `text-secondary` | `text-slate-600` | `#475569` | Secondary text, labels |
|
||||
| `text-muted` | `text-slate-400` | `#94a3b8` | Disabled text, placeholders |
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
| Semantic | Light | Dark | Usage |
|
||||
|---|---|---|---|
|
||||
| `success` | `#16a34a` (green-600) | `#22c55e` (green-500) | Confirmations, valid states |
|
||||
| `warning` | `#d97706` (amber-600) | `#f59e0b` (amber-500) | Caution states, non-blocking issues |
|
||||
| `error` | `#dc2626` (red-600) | `#ef4444` (red-500) | Errors, destructive actions |
|
||||
| `info` | `#2563eb` (blue-600) | `#3b82f6` (blue-500) | Informational highlights |
|
||||
|
||||
Each semantic color has a background variant at 10% opacity for alert/badge backgrounds:
|
||||
- `bg-success`: `success / 10%` over card background
|
||||
- Same pattern for `warning`, `error`, `info`
|
||||
|
||||
### Theme Implementation
|
||||
|
||||
Theme is managed by `next-themes` with the `attribute="class"` strategy. Tailwind's `darkMode: "class"` is enabled. All theme-dependent styles use the `dark:` prefix.
|
||||
|
||||
```tsx
|
||||
// layout.tsx
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
CSS custom properties for the accent color are set on `<html>` based on the active company selection:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--accent: 34 181 171; /* #22B5AB in RGB */
|
||||
--accent-foreground: 255 255 255;
|
||||
}
|
||||
```
|
||||
|
||||
Components reference accent via `bg-[rgb(var(--accent))]` or through shadcn/ui's HSL-based theming variables in `globals.css`.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
Primary: `Inter` (loaded via `next/font/google`), falling back to `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`.
|
||||
|
||||
Monospace (for code, XML output, data fields): `"JetBrains Mono", "Fira Code", ui-monospace, monospace`.
|
||||
|
||||
### Size Scale
|
||||
|
||||
All sizes reference Tailwind's default scale. Do not use arbitrary pixel values.
|
||||
|
||||
| Token | Tailwind | Size | Usage |
|
||||
|---|---|---|---|
|
||||
| `text-xs` | `text-xs` | 0.75rem / 12px | Badges, fine print, metadata |
|
||||
| `text-sm` | `text-sm` | 0.875rem / 14px | Labels, secondary text, table cells |
|
||||
| `text-base` | `text-base` | 1rem / 16px | Body text, input values |
|
||||
| `text-lg` | `text-lg` | 1.125rem / 18px | Card titles, section headers |
|
||||
| `text-xl` | `text-xl` | 1.25rem / 20px | Page section titles |
|
||||
| `text-2xl` | `text-2xl` | 1.5rem / 24px | Page titles |
|
||||
| `text-3xl` | `text-3xl` | 1.875rem / 30px | Dashboard hero stats only |
|
||||
|
||||
### Font Weight
|
||||
|
||||
| Weight | Tailwind | Usage |
|
||||
|---|---|---|
|
||||
| 400 | `font-normal` | Body text |
|
||||
| 500 | `font-medium` | Labels, table headers, navigation items |
|
||||
| 600 | `font-semibold` | Card titles, section headers |
|
||||
| 700 | `font-bold` | Page titles, stat values |
|
||||
|
||||
### Line Height
|
||||
|
||||
Use Tailwind defaults. Override only for tight stat displays (`leading-tight` / `leading-none` on large numeric values).
|
||||
|
||||
---
|
||||
|
||||
## Component Library
|
||||
|
||||
### Base: shadcn/ui
|
||||
|
||||
All interactive components are built on [shadcn/ui](https://ui.shadcn.com/). Components are installed into `src/shared/components/ui/` via the shadcn CLI and customized in place.
|
||||
|
||||
**Customization approach:**
|
||||
|
||||
1. Install the shadcn/ui component (`npx shadcn@latest add button`).
|
||||
2. The component lands in `src/shared/components/ui/button.tsx`.
|
||||
3. Modify theme tokens in `globals.css` to match our color system.
|
||||
4. Extend component variants if needed (e.g., adding a `brand` variant to `Button`).
|
||||
5. Never wrap shadcn components in another abstraction layer unless adding substantial logic. Use them directly.
|
||||
|
||||
**Key shadcn/ui components in use:**
|
||||
|
||||
- `Button` -- primary actions, secondary actions, destructive actions, ghost navigation
|
||||
- `Input`, `Textarea`, `Select` -- form controls
|
||||
- `Card`, `CardHeader`, `CardContent`, `CardFooter` -- content containers
|
||||
- `Dialog`, `AlertDialog` -- modal interactions (replaces `alert()`/`confirm()`)
|
||||
- `DropdownMenu` -- context menus, overflow actions
|
||||
- `Tabs` -- in-page mode switching (e.g., simple/advanced toggle)
|
||||
- `Table` -- data display
|
||||
- `Badge` -- status indicators, category labels
|
||||
- `Tooltip` -- icon-only button labels
|
||||
- `Separator` -- visual dividers
|
||||
- `Switch`, `Checkbox` -- boolean toggles
|
||||
- `Breadcrumb` -- navigation breadcrumbs in header
|
||||
- `Sidebar` -- app shell navigation (shadcn sidebar component)
|
||||
- `Sheet` -- mobile navigation drawer
|
||||
|
||||
---
|
||||
|
||||
## Layout System
|
||||
|
||||
### App Shell
|
||||
|
||||
The application uses a fixed sidebar + header + scrollable content area layout.
|
||||
|
||||
```
|
||||
+--------------------------------------------------+
|
||||
| [Sidebar] | [Header: breadcrumbs | theme | co] |
|
||||
| |--------------------------------------|
|
||||
| Nav | |
|
||||
| items | [Content area] |
|
||||
| | |
|
||||
| grouped | max-w-6xl mx-auto px-6 py-6 |
|
||||
| by | |
|
||||
| category | |
|
||||
| | |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
### Sidebar
|
||||
|
||||
- Width: `16rem` (256px) expanded, `3rem` (48px) collapsed (icon-only mode).
|
||||
- Collapsible via a toggle button at the bottom of the sidebar.
|
||||
- Collapse state persisted to localStorage via a cookie (for SSR compatibility with `next-themes`).
|
||||
- Background: `bg-slate-900` (dark) / `bg-white` (light) with a right border.
|
||||
- Navigation items are grouped by category. Groups are driven by the module registry (`src/config/modules.ts`).
|
||||
|
||||
**Navigation structure:**
|
||||
|
||||
```
|
||||
INSTRUMENTE (Tools)
|
||||
- Semnatura Email
|
||||
- Generator XML Word
|
||||
|
||||
ADMINISTRARE (Admin)
|
||||
- Etichete Proiecte (Project Tags)
|
||||
- Configurare (Settings)
|
||||
|
||||
(future groups added via module registry)
|
||||
```
|
||||
|
||||
Each nav item displays:
|
||||
- A Lucide icon (24x24)
|
||||
- The module label (from `labels.ts`)
|
||||
- An optional badge (e.g., count of saved configs)
|
||||
|
||||
Active state: teal left border + teal-tinted background (`bg-teal-500/10`).
|
||||
|
||||
### Header
|
||||
|
||||
Fixed at the top of the content area. Contains:
|
||||
|
||||
1. **Breadcrumbs** (left): Module group > Module name > Sub-page. Uses shadcn `Breadcrumb`.
|
||||
2. **Company selector** (center-right): Dropdown to switch active company context. Drives branding colors and available configurations.
|
||||
3. **Theme toggle** (right): Sun/Moon icon button. Toggles between dark/light via `next-themes`.
|
||||
4. **User area** (far right, future): Avatar + dropdown for auth when implemented.
|
||||
|
||||
Height: `h-14` (56px). Bottom border separator.
|
||||
|
||||
### Content Area
|
||||
|
||||
- Container: `max-w-6xl mx-auto px-4 sm:px-6 py-6`
|
||||
- For full-width tools (e.g., XML generator with preview): use `max-w-7xl` or remove max-width constraint.
|
||||
- Content scrolls independently of sidebar and header.
|
||||
|
||||
---
|
||||
|
||||
## Card Patterns
|
||||
|
||||
### Primary Content Card
|
||||
|
||||
The standard content container. Used for forms, configuration panels, output displays.
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configurare Semnatura</CardTitle>
|
||||
<CardDescription>Completati datele pentru generarea semnaturii.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* form fields, content */}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{/* action buttons */}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
Styling: `rounded-xl border` with theme-appropriate background. No drop shadows in light mode (border is sufficient). Subtle shadow in dark mode (`shadow-lg shadow-black/20`).
|
||||
|
||||
### Stat Card
|
||||
|
||||
For dashboard counters and KPIs.
|
||||
|
||||
```tsx
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-3xl font-bold leading-none mt-1">{value}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-teal-500/10 p-3">
|
||||
<Icon className="h-6 w-6 text-teal-500" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Widget Card
|
||||
|
||||
For dashboard widgets (quick actions, recent items, external links).
|
||||
|
||||
Same structure as primary content card but with a fixed height and internal scroll if content overflows. Header includes an optional "View all" link.
|
||||
|
||||
---
|
||||
|
||||
## Form Patterns
|
||||
|
||||
### Input Groups
|
||||
|
||||
Each form field follows this structure:
|
||||
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="field-id">{labels.fieldName}</Label>
|
||||
<Input id="field-id" {...props} />
|
||||
<p className="text-xs text-muted-foreground">{labels.fieldHint}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- Labels come from the label constants file (never hardcoded Romanian strings in JSX).
|
||||
- Hints are optional `text-xs text-muted-foreground` paragraphs below the input.
|
||||
- Required fields: no asterisk; instead, validation messages appear on submit.
|
||||
|
||||
### Form Layout
|
||||
|
||||
- Single column for simple forms.
|
||||
- Two-column grid (`grid grid-cols-1 md:grid-cols-2 gap-4`) for forms with many short fields.
|
||||
- Full-width fields (textareas, complex inputs) span both columns via `md:col-span-2`.
|
||||
|
||||
### Validation Display
|
||||
|
||||
Validation errors appear below the field as `text-xs text-destructive`:
|
||||
|
||||
```tsx
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
```
|
||||
|
||||
Input border turns red on error: `border-destructive`.
|
||||
|
||||
### Romanian Labels
|
||||
|
||||
All user-facing text is sourced from `src/core/i18n/labels.ts`. Components reference label keys:
|
||||
|
||||
```tsx
|
||||
import { labels } from '@/core/i18n/labels';
|
||||
|
||||
<Label>{labels.signature.fieldName}</Label>
|
||||
```
|
||||
|
||||
Never write Romanian text directly in JSX. This ensures consistency, makes future i18n possible, and centralizes all copy for review.
|
||||
|
||||
### Label File Structure
|
||||
|
||||
```ts
|
||||
// src/core/i18n/labels.ts
|
||||
|
||||
export const labels = {
|
||||
common: {
|
||||
save: 'Salveaza',
|
||||
cancel: 'Anuleaza',
|
||||
delete: 'Sterge',
|
||||
download: 'Descarca',
|
||||
copy: 'Copiaza',
|
||||
generate: 'Genereaza',
|
||||
reset: 'Reseteaza',
|
||||
loading: 'Se incarca...',
|
||||
noData: 'Nu exista date.',
|
||||
confirm: 'Confirmare',
|
||||
search: 'Cauta',
|
||||
},
|
||||
nav: {
|
||||
tools: 'Instrumente',
|
||||
admin: 'Administrare',
|
||||
emailSignature: 'Semnatura Email',
|
||||
xmlGenerator: 'Generator XML Word',
|
||||
tagManager: 'Etichete Proiecte',
|
||||
settings: 'Configurare',
|
||||
},
|
||||
signature: {
|
||||
title: 'Configurator Semnatura Email',
|
||||
fieldPrefix: 'Titulatura (prefix)',
|
||||
fieldName: 'Nume si Prenume',
|
||||
fieldRole: 'Functia',
|
||||
fieldPhone: 'Telefon (format 07xxxxxxxx)',
|
||||
sectionColors: 'Culori Text',
|
||||
sectionLayout: 'Stil & Aranjare',
|
||||
sectionOptions: 'Optiuni',
|
||||
optionReply: 'Varianta simpla (fara logo/adresa)',
|
||||
optionSuperReply: 'Super-simpla (doar nume/telefon)',
|
||||
optionSvg: 'Foloseste imagini SVG (calitate maxima)',
|
||||
exportHtml: 'Descarca HTML',
|
||||
preview: 'Previzualizare Live',
|
||||
},
|
||||
xml: {
|
||||
title: 'Generator XML pentru Word',
|
||||
fieldNamespace: 'Namespace URI',
|
||||
fieldRootElement: 'Element radacina',
|
||||
fieldList: 'Lista de campuri (unul pe linie)',
|
||||
modeSimple: 'Simplu',
|
||||
modeAdvanced: 'Avansat',
|
||||
categoryLabel: 'Categorii de date',
|
||||
addCategory: 'Adauga categorie',
|
||||
resetPreset: 'Reset categorie la preset',
|
||||
clearFields: 'Curata campurile',
|
||||
generateAll: 'Genereaza XML pentru toate categoriile',
|
||||
downloadCurrent: 'Descarca XML categorie curenta',
|
||||
downloadZip: 'Descarca ZIP cu toate XML-urile',
|
||||
previewXml: 'XML categorie curenta',
|
||||
previewXpath: 'XPaths categorie curenta',
|
||||
},
|
||||
// Additional module labels added here as modules are built.
|
||||
} as const;
|
||||
|
||||
export type LabelKey = typeof labels;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Table Patterns
|
||||
|
||||
Data tables use shadcn/ui `Table` components with the following conventions:
|
||||
|
||||
- **Header**: `font-medium text-sm text-muted-foreground`, no background color differentiation.
|
||||
- **Rows**: alternating subtle background on hover (`hover:bg-muted/50`).
|
||||
- **Sortable columns**: click header to toggle. Arrow indicator next to sorted column.
|
||||
- **Filtering**: text input above the table, filters by visible columns.
|
||||
- **Pagination**: below the table. "X of Y results" + page controls.
|
||||
- **Empty state**: centered message inside the table area using `labels.common.noData`.
|
||||
|
||||
For complex data tables, use `@tanstack/react-table` as the headless engine with shadcn/ui table components for rendering.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Widgets
|
||||
|
||||
The dashboard home page (`/`) uses a responsive grid of widget cards.
|
||||
|
||||
### Layout
|
||||
|
||||
```tsx
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{/* stat cards row */}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
||||
{/* widget cards */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Widget Types
|
||||
|
||||
1. **Stat counters**: Number of saved signature configs, XML templates, etc. Uses Stat Card pattern.
|
||||
2. **Recent items**: Last 5 saved/exported items across tools. List format inside a widget card.
|
||||
3. **Quick actions**: Buttons to jump to common tasks ("New signature", "New XML template"). Grid of icon+label buttons.
|
||||
4. **External tool links**: If legacy HTML tools are still accessible, link out to them with an "external" icon.
|
||||
|
||||
Widgets are defined in a registry and rendered dynamically. Each widget is a self-contained component that fetches its own data.
|
||||
|
||||
---
|
||||
|
||||
## Icon System
|
||||
|
||||
All icons use [Lucide React](https://lucide.dev/) (`lucide-react` package).
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
import { Mail, FileCode, Settings, ChevronRight } from 'lucide-react';
|
||||
|
||||
<Mail className="h-5 w-5" />
|
||||
```
|
||||
|
||||
**Conventions:**
|
||||
|
||||
- Navigation icons: `h-5 w-5`
|
||||
- Inline icons (next to text): `h-4 w-4`
|
||||
- Stat card icons: `h-6 w-6`
|
||||
- Button icons: `h-4 w-4 mr-2` (left of label) or `h-4 w-4 ml-2` (right of label)
|
||||
- Icon-only buttons must have a `Tooltip` or `aria-label`.
|
||||
|
||||
**Do not** use SVG icons inline. Do not use icon fonts. Do not mix icon libraries.
|
||||
|
||||
---
|
||||
|
||||
## Spacing and Grid Conventions
|
||||
|
||||
All spacing uses Tailwind's default 4px-based scale.
|
||||
|
||||
| Context | Spacing | Tailwind |
|
||||
|---|---|---|
|
||||
| Between form fields | 8px | `space-y-2` |
|
||||
| Between card sections | 16px | `space-y-4` |
|
||||
| Card internal padding | 24px | `p-6` |
|
||||
| Grid gap (cards) | 16px | `gap-4` |
|
||||
| Page padding (desktop) | 24px | `px-6 py-6` |
|
||||
| Page padding (mobile) | 16px | `px-4 py-4` |
|
||||
|
||||
Grid uses Tailwind's `grid` utility with responsive column counts:
|
||||
|
||||
- `grid-cols-1` (mobile)
|
||||
- `md:grid-cols-2` (tablet)
|
||||
- `lg:grid-cols-3` (desktop)
|
||||
- `xl:grid-cols-4` (wide desktop, stat cards only)
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
Using Tailwind's default breakpoints:
|
||||
|
||||
| Breakpoint | Min-width | Typical device |
|
||||
|---|---|---|
|
||||
| `sm` | 640px | Large phone, small tablet |
|
||||
| `md` | 768px | Tablet portrait |
|
||||
| `lg` | 1024px | Tablet landscape, small desktop |
|
||||
| `xl` | 1280px | Desktop |
|
||||
| `2xl` | 1536px | Wide desktop |
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- Below `lg`: sidebar collapses to a mobile drawer (shadcn `Sheet`).
|
||||
- Below `md`: single-column content layout.
|
||||
- At `lg` and above: sidebar visible, two-column form layouts.
|
||||
- At `xl` and above: four-column stat card grid.
|
||||
|
||||
---
|
||||
|
||||
## Animation Guidelines
|
||||
|
||||
Animations are minimal and purposeful. This is a professional tool, not a marketing site.
|
||||
|
||||
**Allowed animations:**
|
||||
|
||||
- **Transitions on interactive elements**: `transition-colors duration-150` on buttons, links, nav items.
|
||||
- **Sidebar collapse/expand**: `transition-[width] duration-200 ease-in-out`.
|
||||
- **Card hover elevation** (optional): `transition-shadow duration-150`.
|
||||
- **Dialog/sheet enter/exit**: Use shadcn/ui defaults (Radix UI built-in animations).
|
||||
- **Skeleton loading**: `animate-pulse` on placeholder blocks during data loading.
|
||||
|
||||
**Not allowed:**
|
||||
|
||||
- Page transition animations.
|
||||
- Bouncing, jiggling, or attention-seeking element animations.
|
||||
- Parallax or scroll-linked effects.
|
||||
- Auto-playing animations that loop indefinitely (except loading spinners).
|
||||
- Transform-based hover effects on buttons (`hover:scale-105` and similar -- this was used in the legacy HTML tools but is too playful for the dashboard).
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Baseline
|
||||
|
||||
- All interactive elements must be keyboard navigable.
|
||||
- Focus rings: `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`.
|
||||
- Color contrast: minimum 4.5:1 for text, 3:1 for large text and UI elements (WCAG AA).
|
||||
- Images and icons: `alt` text on informational images, `aria-hidden="true"` on decorative icons, `aria-label` on icon-only buttons.
|
||||
- Form fields: every `<Input>` has an associated `<Label>` via `htmlFor`/`id`.
|
||||
- Dialogs: use shadcn/ui `Dialog` which handles focus trapping and `aria-*` attributes.
|
||||
- No information conveyed by color alone (always pair with text or icon).
|
||||
- Reduced motion: respect `prefers-reduced-motion` by wrapping non-essential animations in `motion-safe:`.
|
||||
|
||||
---
|
||||
|
||||
## Summary: Design Tokens Quick Reference
|
||||
|
||||
```css
|
||||
/* globals.css -- theme tokens (simplified) */
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--primary: 174 68% 41%; /* #22B5AB */
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--ring: 174 68% 41%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 2%; /* #020617 */
|
||||
--foreground: 210 40% 93%;
|
||||
--card: 222.2 84% 5%; /* #0f172a */
|
||||
--card-foreground: 210 40% 93%;
|
||||
--primary: 174 68% 41%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--ring: 174 68% 41%;
|
||||
}
|
||||
```
|
||||
1094
docs/modules/PROMPT-GENERATOR.md
Normal file
1094
docs/modules/PROMPT-GENERATOR.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user