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:
Marius Tarau
2026-02-17 12:50:25 +02:00
commit 4c46e8bcdd
189 changed files with 33780 additions and 0 deletions

View 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.

View 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 &ldquo;{this.props.moduleName}&rdquo;
</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) |

View 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 |

View 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.

View 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:** ~520 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 |

View 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
```