merge: integrate TradeModule/MoneyRaise remote + DataqueryModule

This commit is contained in:
CEO-OS Deploy 2026-08-27 22:07:28 +02:00
commit ad51bd2930
80 changed files with 4493 additions and 97 deletions

View file

@ -11,9 +11,10 @@ ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/drizzle ./drizzle
RUN chown -R node:node /app
USER node
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3001/health || exit 1
CMD ["node", "dist/main"]
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,26 @@
-- Migration 0009: Financial Intelligence — market signals table
-- Stores macro/FX/rates/news signals ingested from n8n (World Bank, IMF, Eurostat, DBnomics)
CREATE TABLE IF NOT EXISTS "financial_signals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"tenant_id" uuid,
"category" text NOT NULL DEFAULT 'macro',
"region" text NOT NULL DEFAULT 'global',
"niche" text,
"source" text NOT NULL,
"indicator_code" text,
"title" text NOT NULL,
"summary" text NOT NULL,
"raw_value" numeric(18, 4),
"change_percent" numeric(10, 4),
"unit" text,
"severity" text NOT NULL DEFAULT 'info',
"published_at" timestamp NOT NULL DEFAULT now(),
"expires_at" timestamp,
"created_at" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "financial_signals_published_idx"
ON "financial_signals" ("category", "published_at" DESC);
CREATE INDEX IF NOT EXISTS "financial_signals_tenant_idx"
ON "financial_signals" ("tenant_id", "published_at" DESC);

View file

@ -0,0 +1,77 @@
-- CEO OS: Contacts (CRM), Risks (Risk Register), Projects
-- Migration 0010_ceo_os_features
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "contacts" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"organization_id" uuid,
"full_name" text NOT NULL,
"email" text,
"phone" text,
"role" text,
"linkedin_url" text,
"notes" text,
"tags" text[] DEFAULT '{}' NOT NULL,
"source" text,
"consent_status" text DEFAULT 'unknown' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "risks" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"title" text NOT NULL,
"description" text,
"category" text,
"probability" text DEFAULT 'medium' NOT NULL,
"impact" text DEFAULT 'medium' NOT NULL,
"risk_score" integer GENERATED ALWAYS AS (
CASE probability
WHEN 'low' THEN 1
WHEN 'medium' THEN 2
WHEN 'high' THEN 3
WHEN 'critical' THEN 4
ELSE 2
END *
CASE impact
WHEN 'low' THEN 1
WHEN 'medium' THEN 2
WHEN 'high' THEN 3
WHEN 'critical' THEN 4
ELSE 2
END
) STORED,
"status" text DEFAULT 'open' NOT NULL,
"mitigation" text,
"owner" text,
"decision_id" uuid,
"review_due_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "projects" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"organization_id" uuid,
"name" text NOT NULL,
"description" text,
"status" text DEFAULT 'planning' NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"start_date" date,
"due_date" date,
"budget_minor_units" bigint,
"currency" text DEFAULT 'RON',
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);

View file

@ -0,0 +1,61 @@
-- CEO OS: Contracts, Obligations, Pipeline Deals
-- Migration 0011_contracts_pipeline
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "contracts" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"organization_id" uuid,
"title" text NOT NULL,
"contract_type" text NOT NULL DEFAULT 'other',
"status" text NOT NULL DEFAULT 'draft',
"parties" text[] DEFAULT '{}' NOT NULL,
"start_date" date,
"end_date" date,
"value_minor_units" bigint,
"currency" text DEFAULT 'RON',
"notes" text,
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "obligations" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"title" text NOT NULL,
"description" text,
"category" text NOT NULL DEFAULT 'contractual',
"status" text NOT NULL DEFAULT 'pending',
"due_date" date,
"owner" text,
"contract_id" uuid,
"evidence_url" text,
"risk_level" text NOT NULL DEFAULT 'medium',
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pipeline_deals" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"organization_id" uuid,
"contact_id" uuid,
"title" text NOT NULL,
"stage" text NOT NULL DEFAULT 'identified',
"value_minor_units" bigint,
"currency" text DEFAULT 'RON',
"probability" integer DEFAULT 50,
"expected_close_date" date,
"notes" text,
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);

View file

@ -0,0 +1,36 @@
-- CC-066: action_plans + scenarios
CREATE TABLE IF NOT EXISTS "action_plans" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"workspace_id" uuid,
"title" text NOT NULL,
"description" text,
"status" text DEFAULT 'draft' NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"owner" text,
"due_date" date,
"completed_at" timestamp,
"decision_id" uuid,
"goal_id" uuid,
"project_id" uuid,
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
CREATE TABLE IF NOT EXISTS "scenarios" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"decision_id" uuid,
"title" text NOT NULL,
"description" text,
"probability" text DEFAULT 'medium' NOT NULL,
"outcome" text,
"financial_impact_minor_units" numeric(15,0),
"financial_impact_currency" text DEFAULT 'RON' NOT NULL,
"impact_direction" text DEFAULT 'neutral' NOT NULL,
"status" text DEFAULT 'hypothetical' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

View file

@ -0,0 +1,31 @@
-- CC-067: assumptions + outcome_reviews
CREATE TABLE IF NOT EXISTS "assumptions" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"decision_id" uuid,
"statement" text NOT NULL,
"source" text,
"confidence" text DEFAULT 'medium' NOT NULL,
"status" text DEFAULT 'unverified' NOT NULL,
"review_date" date,
"evidence_url" text,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS "outcome_reviews" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"decision_id" uuid,
"title" text NOT NULL,
"period_start" date,
"period_end" date,
"actual_outcome" text,
"was_successful" boolean,
"lessons_learned" text,
"next_steps" text,
"rating" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

View file

@ -0,0 +1,15 @@
-- CC-068: data_sources registry
CREATE TABLE IF NOT EXISTS "data_sources" (
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL,
"tenant_id" uuid NOT NULL,
"name" text NOT NULL,
"source_type" text DEFAULT 'manual' NOT NULL,
"description" text,
"connection_info" text,
"status" text DEFAULT 'active' NOT NULL,
"last_sync_at" timestamp,
"record_count" integer,
"tags" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

View file

@ -57,6 +57,48 @@
"when": 1785331800000,
"tag": "0007_saga_manager",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1785580325133,
"tag": "0009_financial_intelligence",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1785680325133,
"tag": "0010_ceo_os_features",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1785780000000,
"tag": "0011_contracts_pipeline",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1785780325133,
"tag": "0012_action_plans_scenarios",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1785880325133,
"tag": "0013_assumptions_outcomes",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1785980325133,
"tag": "0014_data_sources",
"breakpoints": true
}
]
}

22
money-raise/ingest.sh Normal file
View file

@ -0,0 +1,22 @@
#!/bin/bash
# Run on supersrv: bash ingest.sh
# Requires: Docker with ClickHouse container avs3owrcdr53mqyun9s910mb
set -e
CONTAINER="avs3owrcdr53mqyun9s910mb"
DATA_FILE="$(dirname "$0")/knowledge-base.jsonl"
echo "=== Creating money_raise database and table ==="
docker exec -i "$CONTAINER" clickhouse-client --multiquery < "$(dirname "$0")/schema.sql"
echo "=== Ingesting knowledge base ($(wc -l < "$DATA_FILE") entries) ==="
docker exec -i "$CONTAINER" clickhouse-client \
--query "INSERT INTO money_raise.knowledge_base \
(category, subcategory, region, stage, title, content, format, tags, source, language) \
FORMAT JSONEachRow" < "$DATA_FILE"
echo "=== Verifying ==="
docker exec "$CONTAINER" clickhouse-client \
--query "SELECT category, count() as n FROM money_raise.knowledge_base GROUP BY category ORDER BY n DESC"
echo "=== Done! ==="

View file

@ -0,0 +1,21 @@
{"category": "template", "subcategory": "pitch_deck", "region": "global", "stage": "all", "title": "Pitch Deck Structure — 12 Slides (Global Standard)", "content": "# Pitch Deck — 12 Slide Framework\n\n## Slide 1: Cover\nCompany name, tagline (≤10 words), logo, presenter name, date, CONFIDENTIAL\n\n## Slide 2: Problem\n- 1 specific pain point, quantified (€/h/% lost)\n- Who suffers? (ICP — Ideal Customer Profile)\n- Why now? (market shift, regulation, technology)\n- Emotional hook: story of one real customer\n\n## Slide 3: Solution\n- Product screenshot or 30-sec demo GIF\n- \"We do X for Y so they can Z\"\n- 3 key differentiators MAX\n- Do NOT list features — show outcome\n\n## Slide 4: Market Size\n- TAM (Total Addressable Market) — top-down\n- SAM (Serviceable Addressable Market) — bottom-up validated\n- SOM (Serviceable Obtainable Market) — realistic 3-year target\n- Source every number (Gartner, IDC, Statista, government data)\n- Minimum: €1B TAM for VC interest\n\n## Slide 5: Product / Traction\n- Core product visual + 3 key features\n- Traction table: MRR, ARR, DAU/MAU, NPS, churn, growth %\n- Key milestones hit\n- If early: pilots, LOIs, waitlist size\n\n## Slide 6: Business Model\n- Revenue streams with unit economics\n- Pricing tiers (3 max)\n- CAC, LTV, LTV/CAC ratio (target: >3×)\n- Gross margin % (SaaS target: >70%)\n- Path to profitability\n\n## Slide 7: Go-to-Market\n- Channel breakdown (paid/organic/partnerships/direct)\n- First 100 customers strategy\n- Land-and-expand motion\n- Sales cycle length + ACV by segment\n\n## Slide 8: Competition\n- 2×2 matrix (axes = your 2 key differentiators)\n- Why existing solutions fail\n- Moat: tech IP / data / network / switching cost / brand\n- \"We win because...\" — one clear sentence\n\n## Slide 9: Team\n- Photos + titles + 2 relevant lines each\n- Relevant exits or domain expertise\n- Advisors (only if genuinely involved)\n- Key hires needed (shows self-awareness)\n\n## Slide 10: Financials\n- 3-year P&L projection (monthly Y1, quarterly Y2-3)\n- Key assumptions listed\n- Current burn rate + runway\n- Break-even month highlighted\n- Conservative / base / optimistic scenario\n\n## Slide 11: The Ask\n- Amount raising: €X\n- Round type: Seed / Series A / etc.\n- Instrument: SAFE / Convertible Note / Priced equity\n- Lead investor target + co-investors\n- Use of funds (pie chart or bar): product X%, sales X%, ops X%\n\n## Slide 12: Vision / Why Now\n- 3-year north star metric\n- Exit scenarios: IPO / strategic acquisition / merger\n- Comparable exits in your space\n- Single memorable closing line\n\n## Appendix (optional)\n- Detailed financials, cohort analysis, technical architecture, cap table\n", "format": "markdown", "tags": "[\"pitch\", \"deck\", \"fundraising\", \"template\", \"global\"]", "source": "Sequoia Capital Pitch Advice + YC Application", "language": "en"}
{"category": "template", "subcategory": "executive_summary", "region": "global", "stage": "all", "title": "Executive Summary — 1-Page Template", "content": "# Executive Summary Template (1 page / 500 words max)\n\n**[COMPANY NAME]** — [One-line tagline]\n\n## The Problem\n[2-3 sentences: what pain, who has it, how big is it in €]\n\n## Our Solution\n[2-3 sentences: what you built, how it works, key differentiator]\n\n## Traction\n| Metric | Value |\n|--------|-------|\n| MRR/ARR | €X |\n| Customers | X paying |\n| Growth MoM | X% |\n| Gross Margin | X% |\n\n## Market\n- TAM: €Xbn | SAM: €Xm | SOM: €Xm (3yr)\n- Source: [Gartner/IDC/custom research]\n\n## Business Model\n[Pricing: €X/month/seat. LTV €X, CAC €X, LTV:CAC X:1]\n\n## Team\n- [Name], CEO — [2 relevant lines]\n- [Name], CTO — [2 relevant lines]\n- [Name], [Role] — [2 relevant lines]\n\n## The Ask\nRaising **€[amount]** [instrument: SAFE/Convertible/Equity]\nUse of funds: [Product X% / GTM X% / Ops X%]\n\n## Contact\n[Name] | [email] | [LinkedIn] | [website]\n", "format": "markdown", "tags": "[\"executive_summary\", \"one_pager\", \"template\"]", "source": "YC + Sequoia formats", "language": "en"}
{"category": "template", "subcategory": "term_sheet", "region": "eu", "stage": "seed", "title": "Term Sheet — EU/DACH Convertible Loan (Wandeldarlehen)", "content": "# Convertible Loan Agreement — Key Terms (EU/DACH Standard)\n\n## Parties\n- Investor: [Name/Entity]\n- Company: [GmbH/AG]\n- Date: [Date]\n\n## Financial Terms\n- **Principal Amount:** €[X]\n- **Interest Rate:** 0% 8% p.a. (market: 4-6% for seed)\n- **Term:** 1824 months (trigger: next equity round or maturity)\n- **Valuation Cap:** €[X] pre-money (negotiate: 3-5× current revenue or market comp)\n- **Discount:** 15-25% on conversion price (market standard: 20%)\n- **Minimum Qualified Round:** €[X] (typically 3-5× loan amount)\n\n## Conversion\n- **Auto-conversion trigger:** qualified equity financing ≥ €[minimum]\n- **Conversion price:** lower of (cap ÷ shares) or (round price × (1 - discount))\n- **Maturity conversion:** at investor's option at cap price or repayment\n\n## Investor Protections\n- MFN (Most Favored Nation) clause: yes/no\n- Pro-rata right in next round: yes (up to 2× invested amount)\n- Information rights: quarterly P&L + bank statement\n- Board observer seat: at €[threshold]+ investment\n\n## Repayment\n- On maturity without qualified round: principal + accrued interest\n- Change of control: 1.5× principal OR conversion at cap price\n\n## Governing Law\n- Germany: BGB §§ 488ff, GmbHG\n- Austria: ABGB\n- Switzerland: OR\n\n## Red Flags to Avoid\n- Full ratchet anti-dilution (use broad-based weighted average)\n- >2× liquidation preference\n- Participating preferred without cap\n- No time limit on investor approval rights\n", "format": "markdown", "tags": "[\"term_sheet\", \"convertible\", \"DACH\", \"EU\", \"seed\", \"template\"]", "source": "German Startup Association (Bundesverband Deutsche Startups)", "language": "en"}
{"category": "template", "subcategory": "term_sheet", "region": "us", "stage": "seed", "title": "SAFE Note — Y Combinator Standard (Post-Money)", "content": "# SAFE (Simple Agreement for Future Equity) — Key Terms\n\n## YC Post-Money SAFE (2018 version — industry standard)\n\n### Core Terms\n- **Investment Amount:** $[X]\n- **Post-Money Valuation Cap:** $[X]\n (pre-money equivalent ≈ cap - all SAFEs + this SAFE)\n- **Discount Rate:** [none | 10-20%] (most seed SAFEs: no discount if cap exists)\n- **MFN:** included in YC standard form\n\n### How Conversion Works\nOn a priced round, SAFE converts at:\n`conversion_price = min(cap / post_money_shares, round_price × (1 - discount))`\n\nPost-money cap = you know your dilution at signing. Pre-money cap = you don't.\n**Always use post-money cap.**\n\n### Pro-Rata Rights\n- Standard: no pro-rata in YC SAFE\n- Negotiate: \"Major Investor\" pro-rata side letter at $[threshold]+\n\n### Dissolution / Liquidity\n- M&A before conversion: 1× return OR convert at cap\n- IPO: converts like Series A preferred\n\n### What SAFE Does NOT Include\n- Interest (unlike convertible note)\n- Maturity date\n- Repayment obligation\n- Board seat (usually)\n\n### 4 SAFE Variants\n1. **Cap, no discount** — most founder-friendly, most common seed\n2. **Cap + discount** — common for bridge rounds\n3. **No cap, discount** — rare, investor-friendly\n4. **MFN only** — for very early (pre-product) investments\n\n### Filing & Legal\n- Delaware C-Corp only\n- File with cap table software (Carta, Pulley, Capdesk)\n- Board resolution required\n- No 409A impact (until conversion)\n- Section 1202 QSBS eligibility: maintained\n\nSource: ycombinator.com/documents\n", "format": "markdown", "tags": "[\"SAFE\", \"YC\", \"convertible\", \"us\", \"seed\", \"template\"]", "source": "Y Combinator", "language": "en"}
{"category": "template", "subcategory": "data_room", "region": "global", "stage": "seed", "title": "Data Room Structure — Investor Due Diligence", "content": "# Fundraising Data Room Structure\n\n## Folder Structure\n```\n📁 [Company] Data Room — [Round] [Date]\n├── 00_Executive\n│ ├── Pitch Deck (latest).pdf\n│ ├── Executive Summary.pdf\n│ ├── One-Pager.pdf\n│ └── Investor Update (last 3).pdf\n│\n├── 01_Legal\n│ ├── Certificate of Incorporation\n│ ├── Articles of Association (current)\n│ ├── Shareholder Register\n│ ├── Board Resolutions (significant)\n│ ├── Cap Table (Carta export or Excel)\n│ ├── Existing Agreements (SAFEs, convertible notes)\n│ ├── Option Pool (ESOP plan + grants)\n│ └── IP Assignments (founders + employees)\n│\n├── 02_Financial\n│ ├── P&L — Last 24 months (actual)\n│ ├── Balance Sheet — Current\n│ ├── Cash Flow — Actual vs projected\n│ ├── Financial Model — 3yr projection\n│ ├── Bank Statements — Last 6 months\n│ ├── Revenue Details (by customer/segment)\n│ └── Tax Returns — Last 2 years\n│\n├── 03_Product\n│ ├── Product Roadmap (12 months)\n│ ├── Technical Architecture\n│ ├── Security Overview\n│ ├── Patents / IP documentation\n│ └── Demo video / walkthrough\n│\n├── 04_Commercial\n│ ├── Customer List (top 20 + metrics)\n│ ├── Sample Contracts (redacted)\n│ ├── Pipeline (CRM export)\n│ ├── Churn Analysis\n│ ├── NPS / Customer Feedback\n│ └── Reference Customers (3-5)\n│\n├── 05_Team\n│ ├── Org Chart\n│ ├── Employment Agreements (key team)\n│ ├── Contractor Agreements\n│ ├── Advisor Agreements\n│ └── LinkedIn profiles (key team)\n│\n└── 06_Market\n ├── Market Research\n ├── Competitive Analysis\n ├── Customer Interviews (5+)\n └── Press / Media Coverage\n```\n\n## Access Control\n- NDA required before link sharing\n- Expiring links (DocSend/Notion/Dealroom)\n- Track: who viewed, which slides, time spent\n- Redact: employee salaries, individual customer revenue\n\n## Red Flags Investors Look For\n- Missing IP assignments from founders\n- No ESOP plan or vague cap table\n- Missing early customer contracts\n- Inconsistency between deck numbers and financials\n- No documentation of key decisions/board resolutions\n", "format": "markdown", "tags": "[\"data_room\", \"due_diligence\", \"template\", \"fundraising\"]", "source": "DocSend + Notion + Dealroom best practices", "language": "en"}
{"category": "standard", "subcategory": "regulation", "region": "us", "stage": "all", "title": "SEC Regulation D — US Private Placement Rules", "content": "# SEC Regulation D — Private Fundraising in the US\n\n## What It Is\nReg D is a SEC safe harbor allowing companies to raise unlimited capital\nfrom accredited investors without full SEC registration.\n\n## Key Exemptions\n### Rule 506(b) — Most Common\n- Up to 35 non-accredited sophisticated investors + unlimited accredited\n- NO general solicitation allowed (no public advertising, LinkedIn posts, etc.)\n- Accredited investors: self-certified\n- Must file Form D within 15 days of first sale\n- No limit on raise amount\n\n### Rule 506(c) — General Solicitation Allowed\n- Only accredited investors\n- Must VERIFY accredited status (tax returns, bank statements, third-party letter)\n- Can publicly advertise the offering\n- File Form D within 15 days\n\n## Accredited Investor Definition (2020 update)\nIndividual: net worth >$1M (ex primary residence) OR income >$200K ($300K joint) last 2 years\nEntity: assets >$5M OR all equity owners are accredited\nNew: Series 65 license holders, knowledgeable employees of private funds\n\n## Form D Filing\n- File at sec.gov/cgi-bin/browse-edgar\n- Required: company info, exemption type, amount, investors, date\n- Annual amendment if raise continues >12 months\n- State blue sky filings also required (each state where investors located)\n\n## Common Mistakes\n- General solicitation under 506(b) = immediate disqualification\n- Missing Form D filing = potential SEC enforcement\n- Selling to non-accredited without proper disclosure\n- Not checking state securities laws (blue sky)\n\n## For European companies raising in US\n- Delaware C-Corp strongly preferred\n- Avoid raising from >35 US persons in any 12-month period without SEC compliance\n- PIPE (Private Investment in Public Equity) for public companies: different rules\n", "format": "markdown", "tags": "[\"SEC\", \"regulation_d\", \"us\", \"private_placement\", \"standard\"]", "source": "SEC.gov", "language": "en"}
{"category": "standard", "subcategory": "regulation", "region": "eu", "stage": "all", "title": "EU Prospectus Regulation + ESMA Crowdfunding", "content": "# EU Fundraising Regulations\n\n## EU Prospectus Regulation (2021)\n### When a Prospectus is Required\n- Public offering of securities ≥ €8M (raised from Regulation 2021/337)\n- Cross-border EU offering\n\n### Exemptions (no prospectus needed)\n- Offering to fewer than 150 persons per member state\n- Minimum denomination ≥ €100,000\n- Total raise < €8M over 12 months (national regime)\n- Offering only to qualified investors (institutional)\n\n### Simplified Prospectus\n- SME Growth Markets exemption\n- Maximum 75 pages\n- For companies listed on SME growth markets\n\n## ESMA Crowdfunding Regulation (EU) 2020/1503\n### Scope\n- Applies to crowdfunding service providers in EU\n- Covers: equity crowdfunding, loan-based crowdfunding\n- Maximum per project: €5M per 12-month rolling period\n\n### Key Rules for Platforms\n- ESMA authorisation required\n- KYC for investors: basic (≤€1K) and sophisticated (unlimited)\n- Non-sophisticated investor: 30-day reflection period\n- Investment limit per non-sophisticated: €1,000 or 10% annual income\n\n### Country-Specific National Regimes (≤€5M)\n| Country | Threshold | Authority |\n|---------|-----------|-----------|\n| Germany | €6M (VermAnlG) | BaFin |\n| France | €8M | AMF |\n| Netherlands | €5M | AFM |\n| Spain | €5M | CNMV |\n| Romania | €5M | ASF |\n\n## AIFMD (Alternative Investment Fund Managers Directive)\n- Applies to: VCs, PE funds, hedge funds >€100M\n- Authorization required in EU member state\n- NPPR (National Private Placement Regime) for non-EU managers\n", "format": "markdown", "tags": "[\"EU\", \"prospectus\", \"ESMA\", \"crowdfunding\", \"regulation\", \"standard\"]", "source": "EUR-Lex + ESMA", "language": "en"}
{"category": "standard", "subcategory": "regulation", "region": "dach", "stage": "all", "title": "Germany Fundraising — BaFin + INVEST Program", "content": "# Germany Startup Fundraising Standards\n\n## BaFin Regulatory Framework\n### VermAnlG (Vermögensanlagengesetz)\n- Applies to: profit-sharing rights, subordinated loans, silent partnerships\n- Prospectus required for: >€2.5M OR >20 investors\n- Exemptions: <€100K total, max 20 investors, or qualified investors only\n- Crowdinvesting exemption: up to €6M via licensed platform (§2a VermAnlG)\n\n### WpPG (Wertpapierprospektgesetz)\n- Applies to transferable securities (GmbH shares are NOT transferable securities)\n- AG (Aktiengesellschaft) shares: WpPG applies\n- GmbH shares: VermAnlG or private placement exemption\n\n## INVEST Program (Bundesamt für Wirtschaft und Ausfuhrkontrolle)\n### For Business Angels investing in German startups\n- State refund: 20% of investment for angels (up to €500K investment = €100K refund)\n- Company eligibility: <7 years old, <250 employees, <€50M revenue\n- Investment minimum: €10,000 per investor\n- Angel holds shares: minimum 3 years\n- Application: bafa.de before investment\n\n## KfW Programs (Kreditanstalt für Wiederaufbau)\n- **ERP-Gründerkredit StartGeld:** up to €125K, 80% guarantee, <5 years old\n- **ERP-Gründerkredit Universell:** up to €25M for growth\n- **KfW Venture Fonds:** co-investment with private VCs (pari passu)\n- Via partner banks (Sparkasse, Deutsche Bank, etc.)\n\n## High-Tech Gründerfonds (HTGF)\n- Germany's most active seed investor\n- Ticket: €600K-€3M seed, can follow on\n- Takes 15% + convertible loan\n- Sector focus: technology, science-based\n- Apply: htgf.de/en\n\n## Key DACH VC Ecosystem\n- HV Capital (Munich/Berlin): Series A-C, €5M-€50M\n- Earlybird: pan-European, deep tech + digital\n- Cherry Ventures: pre-seed/seed, consumer + B2B\n- Project A: seed + Series A, operational support\n- Lakestar: growth stage, €10M-€100M\n- Cavalry Ventures: pre-seed, Berlin ecosystem\n", "format": "markdown", "tags": "[\"Germany\", \"BaFin\", \"INVEST\", \"KfW\", \"DACH\", \"standard\"]", "source": "BaFin.de + BAFA.de + HTGF", "language": "en"}
{"category": "standard", "subcategory": "regulation", "region": "uk", "stage": "all", "title": "UK EIS/SEIS — Tax Relief for Startup Investors", "content": "# UK Enterprise Investment Scheme (EIS & SEIS)\n\n## SEIS — Seed Enterprise Investment Scheme\n### Investor Benefits\n- **50% income tax relief** on investment amount (up to £200K/year)\n- **CGT exemption** on gains if held 3+ years\n- **Loss relief**: offset losses against income tax at marginal rate\n- **CGT deferral** on previous gains reinvested via SEIS\n\n### Company Eligibility\n- UK resident trading company\n- Assets < £350K at time of investment (raised 2023)\n- Fewer than 25 full-time employees\n- Less than 2 years old (trading start)\n- NOT: property, hotels, nursing homes, financial services\n\n### Max Raise: £250,000 lifetime per company via SEIS\n\n## EIS — Enterprise Investment Scheme\n### Investor Benefits\n- **30% income tax relief** on investment (up to £1M/year; £2M if knowledge-intensive)\n- **CGT exemption** on gains if held 3+ years\n- **Loss relief** + **CGT deferral**\n- Estate planning: IHT exemption after 2 years\n\n### Company Eligibility\n- UK resident, qualifying trade (broader than SEIS)\n- Assets < £15M before raise\n- Fewer than 250 full-time employees\n- Within 7 years of first commercial sale\n\n### Max Raise: £5M/year, £12M lifetime (£20M for knowledge-intensive)\n\n## HMRC Process\n1. Company applies for HMRC Advance Assurance (recommended, not required)\n2. Investment made → company files EIS1/SEIS1 with HMRC\n3. HMRC issues EIS3/SEIS3 certificates to investors (4-8 weeks)\n4. Investors claim tax relief on self-assessment\n\n## Knowledge-Intensive Companies (KIC)\n- R&D spending: >15% of operating costs in last 3 years OR >10% in each of last 3\n- OR: creating/acquiring intellectual property\n- Doubled limits: £2M investor annual relief, £20M company lifetime\n\n## British Business Bank\n- Future Fund: matched convertible loans (COVID era, now closed)\n- British Patient Capital: LP in VC funds\n- Enterprise Capital Funds: VC fund-of-funds backing UK VCs\n- Start Up Loans: up to £25K, personal loan, no equity\n", "format": "markdown", "tags": "[\"UK\", \"EIS\", \"SEIS\", \"HMRC\", \"tax_relief\", \"standard\"]", "source": "HMRC + British Business Bank", "language": "en"}
{"category": "standard", "subcategory": "regulation", "region": "ro", "stage": "all", "title": "Romania Startup Funding — Grants + Ecosystem", "content": "# Romania Startup Fundraising\n\n## Legal Structure\n- **SRL** (Societate cu Răspundere Limitată): standard for startups\n - Minimum capital: 200 RON (symbolic)\n - Shares: parts sociale (not freely transferable without notarial act)\n - For VC investment: often converted to SA before Series A\n- **SA** (Societate pe Acțiuni): for larger raises, stock options easier\n - Minimum capital: 90,000 RON\n - Required for: stock exchange listing, ESOP structures\n\n## ASF (Autoritatea de Supraveghere Financiară)\n- Regulates: securities, crowdfunding platforms\n- Prospectus threshold: €8M (EU standard applies)\n- Crowdfunding: ESMA Regulation applies from 2023\n- Licensed platforms: seedblink.com (most active RO equity crowdfunding)\n\n## Grants & Non-Dilutive Funding\n### PNRR (Plan Național de Redresare și Reziliență)\n- Component 9 — Suport pentru sectorul privat\n- Digitalizare: up to €1M per SME\n- Apply via: fonduri-structurale.ro\n\n### StartUp Nation România\n- Up to €45,000 non-refundable per startup\n- Requirements: create ≥1 job, maintain 3 years\n- Open periodically via imm.gov.ro\n\n### IMM Invest Plus\n- Loan guarantees up to 90% for SMEs\n- Via commercial banks (BCR, BRD, ING)\n- For: working capital + investments\n\n### Horizon Europe (for RO companies)\n- EIC Accelerator: up to €2.5M grant + €15M equity\n- Open: eic.ec.europa.eu\n- No repayment for grant portion\n\n## Active Romanian Investors\n- **Early Game Ventures**: pre-seed/seed, €100K-€500K, tech focus\n- **GapMinder VC**: seed-Series A, €500K-€3M, regional\n- **ROCA-X**: seed/Series A, smart capital\n- **Morphosis Capital**: growth equity, €2M-€10M\n- **Catalyst Romania Fund**: SME growth, IFC backed\n- **SeedBlink**: equity crowdfunding, average €500K rounds\n\n## Key Events\n- How to Web (Bucharest, October): largest startup conference RO\n- Innovation Labs: accelerator (Bucharest + Cluj)\n- Spherik Accelerator (Cluj-Napoca)\n- TechAngels: angel network\n", "format": "markdown", "tags": "[\"Romania\", \"SRL\", \"SA\", \"grants\", \"PNRR\", \"ASF\", \"standard\"]", "source": "ASF.ro + fonduri-structurale.ro", "language": "en"}
{"category": "protocol", "subcategory": "investor_outreach", "region": "global", "stage": "all", "title": "Investor Outreach Protocol — CRM + Pipeline", "content": "# Investor Outreach Protocol\n\n## Phase 1: Research & Targeting (2-4 weeks before launch)\n1. Build target list: 100-150 investors (VCs + angels + family offices)\n2. Qualify each: portfolio fit, stage, check size, lead/follow\n3. Tier them:\n - Tier A (20-30): dream investors, best fit, warm intro needed\n - Tier B (40-50): good fit, can cold outreach\n - Tier C (30-40): fallback, less ideal\n\n## Phase 2: Warm Intro Mapping\n- Map your network to Tier A investors via LinkedIn\n- Ask 3 specific people for intros (not broadcast)\n- Intro request template:\n > \"Hi [Name], I'm raising [round] for [Company].\n > I'd love a warm intro to [Investor] at [Fund].\n > I've prepared a 2-sentence blurb if you need it. Worth your while?\"\n\n## Phase 3: Launch (structured 4-week window)\n- Start with Tier B (practice conversations)\n- Week 2: Tier A meetings (momentum from earlier interest)\n- Never: first meeting = pitch. First meeting = founder story + listening\n- Target: 3-5 meetings/week, no more (you still run the company)\n\n## CRM Structure (Notion/Airtable/Attio)\n| Field | Values |\n|-------|--------|\n| Status | Researching / Emailing / Intro Requested / Meeting Scheduled / Pitched / DD / Term Sheet / Passed / Closed |\n| Last Contact | Date |\n| Next Action | Text |\n| Partner | Name at fund |\n| Check Size | Range |\n| Fit | 1-5 stars |\n\n## Cold Outreach Formula\nSubject: \"[Warm connection] → [Company] — [1 number that matters]\"\nBody (5 sentences MAX):\n1. Who you are (1 line)\n2. What you built (1 line)\n3. Why them specifically (1 line — shows homework)\n4. The number (traction/milestone)\n5. CTA: \"15 min call this week?\"\n\n## Follow-Up Rules\n- No response: follow up once after 5 business days\n- After meeting: follow up within 24h with deck + data room link\n- \"We'll be in touch\": follow up with monthly investor updates\n- Soft no: ask for one intro/referral\n\n## Momentum Tactics\n- Create FOMO: \"We're closing in 3 weeks\"\n- Social proof: \"We have a term sheet from [credible fund]\"\n- Anchor: set deadline before you have one (works)\n", "format": "markdown", "tags": "[\"outreach\", \"CRM\", \"pipeline\", \"protocol\", \"investor_relations\"]", "source": "Ycombinator + FirstRound Capital playbooks", "language": "en"}
{"category": "protocol", "subcategory": "due_diligence", "region": "global", "stage": "seed", "title": "Due Diligence Checklist — Sell-Side Preparation", "content": "# Due Diligence Preparation Protocol (Founders)\n\n## Before DD Starts — Pre-Flight Checklist\n- [ ] Cap table clean and current (Carta/Pulley/spreadsheet)\n- [ ] All IP assigned to company (founder IP assignment agreements)\n- [ ] Employment agreements for all employees\n- [ ] Contractor agreements with IP assignment clause\n- [ ] Clean data room (no personal files, no draft documents)\n\n## Legal DD — Common Red Flags & Fixes\n### Cap Table Issues\n- [ ] Unvested founder shares with cliff + 4yr vesting\n- [ ] Pre-incorporation IP (what did founders build before company?)\n- [ ] Missing 83(b) elections (US) — cannot be fixed retroactively\n- [ ] Option grants missing board approval\n- FIX: Clean up 3-6 months before raise\n\n### Contract Risks\n- [ ] Revenue recognition: is ARR recognized correctly?\n- [ ] Customer contracts: auto-renewal, termination, IP ownership clauses\n- [ ] Key supplier contracts: change-of-control provisions\n- [ ] Open source licenses: GPL contamination check\n\n## Financial DD — What Investors Verify\n- Monthly cohort retention (logo and revenue)\n- CAC by channel (don't blend channels)\n- Gross margin at unit level (exclude amortized costs)\n- Deferred revenue recognition\n- Related-party transactions\n\n## Technical DD (Series A+)\n- Code quality: automated tests coverage %\n- Security: penetration test (at least self-assessment)\n- Scalability: architecture can handle 10× without full rewrite?\n- Key person risk: is critical code known only to 1 person?\n- Third-party dependencies: key vendor contracts, API terms\n\n## 10 Questions Every Investor Will Ask\n1. Why hasn't [bigger competitor] done this?\n2. What happens if Google/Microsoft builds this?\n3. What's your real CAC (not blended)?\n4. What's your net revenue retention (NRR)?\n5. Why will you win vs the 5 others doing this?\n6. Why are you the right team?\n7. What do your best customers say about you?\n8. What would you do differently with 10× the money?\n9. What keeps you up at night?\n10. What's your 5-year exit scenario?\n\n## Red Lines (auto-pass for most VCs)\n- Founder with criminal record (undisclosed)\n- Revenue restatement required\n- Core IP not owned by company\n- Key customer = related party\n- No product-market fit evidence\n", "format": "checklist", "tags": "[\"due_diligence\", \"legal\", \"financial\", \"technical\", \"protocol\"]", "source": "FirstRound + a16z DD playbooks", "language": "en"}
{"category": "protocol", "subcategory": "negotiation", "region": "global", "stage": "seed", "title": "Term Sheet Negotiation Protocol", "content": "# Term Sheet Negotiation — What Matters vs What Doesn't\n\n## The 3 Terms That Actually Matter Most\n1. **Valuation** — your dilution at this round\n2. **Liquidation preference** — who gets paid first in exit\n3. **Board composition** — who controls the company\n\nEverything else: negotiate but don't die on those hills.\n\n## Valuation Negotiation\n- Counter: 20-30% above lead's offer if you have alternatives\n- Anchor: \"We've had conversations at [higher number]\"\n- BATNA: Have at least 2 term sheets before negotiating the first\n- Timing: get term sheets close together (2-week window target)\n\n## Liquidation Preference\n| Type | Effect | Founder-Friendly? |\n|------|--------|-------------------|\n| 1× non-participating | Investor gets 1× OR converts to pro-rata | ✅ Good |\n| 1× participating (capped at 3×) | Gets 1× THEN shares in remainder up to 3× | ⚠️ OK |\n| 1× participating (uncapped) | Gets 1× THEN full pro-rata in remainder | ❌ Bad |\n| 2× non-participating | 2× first, then done | ❌ Bad |\n| Full ratchet | Price protection, very dilutive | ❌ Never accept |\n\n**Fight for:** 1× non-participating. This is now standard at good funds.\n\n## Board Composition (Seed)\n- Pre-seed: no board, advisory board only\n- Seed: 3-person board: 2 founders + 1 investor (or 2+1+1 independent)\n- Series A: 5-person: 2 founders, 2 investors, 1 independent\n- **Protect:** founder majority until Series B minimum\n\n## Anti-Dilution\n- **Broad-based weighted average** = standard, acceptable\n- **Narrow-based weighted average** = less good\n- **Full ratchet** = never accept, extremely punitive\n\n## Terms You Can Concede Easily\n- Pro-rata rights (in moderation): OK to give\n- Information rights: standard, give them\n- ROFR/ROFO on shares: normal protective provision\n- Drag-along: acceptable if well-structured\n\n## Process\n1. Receive term sheet → 24h rule: never respond same day\n2. Review with lawyer (3-4h = €1,500-3,000, worth it)\n3. Pick your top 3 asks, ignore the rest\n4. Negotiate by phone, not email\n5. Close in ≤2 weeks from signed term sheet\n\n## The Founder's Leverage\n- Your BATNA (other term sheets)\n- Momentum (revenue growth during process)\n- Deadline (artificial scarcity works)\n- FOMO (who else is interested)\n", "format": "markdown", "tags": "[\"negotiation\", \"term_sheet\", \"protocol\", \"valuation\", \"board\"]", "source": "Brad Feld \"Venture Deals\" + Elad Gil \"High Growth Handbook\"", "language": "en"}
{"category": "roadmap", "subcategory": "pre_seed", "region": "global", "stage": "pre_seed", "title": "Pre-Seed Fundraising Roadmap (€50K€1M)", "content": "# Pre-Seed Fundraising Roadmap\n\n## What Pre-Seed Is\n- Stage: idea → MVP → first customers\n- Amount: €50K€1M (sweet spot: €200-500K)\n- Dilution: 10-20% (target: ≤15%)\n- Instrument: SAFE or convertible note (avoid priced round at this stage)\n- Timeline: 2-4 months\n\n## Before You Raise\nMinimum viable traction to raise pre-seed:\n- Option A: 3-5 paying customers + clear problem validation\n- Option B: 1,000+ waitlist with strong conversion signal\n- Option C: Founder has done it before (serial entrepreneur)\n- Option D: Deep technical moat + research breakthrough\n\n## Month 1-2: Preparation\n- [ ] Define: what do you need the money for? (product/hire/growth)\n- [ ] Calculate: how much runway does this give you? (target: 18 months)\n- [ ] Build: short deck (10 slides), exec summary, data room basics\n- [ ] Activate: your personal network (ex-colleagues, uni network, communities)\n- [ ] Target: angels first (faster decision, less DD, better terms)\n\n## Who to Target at Pre-Seed\n1. **Angels from your industry** — add credibility + intro network\n2. **Operator angels** — executives who've done it (LinkedIn search)\n3. **Alumni angels** — your university's angel network\n4. **Pre-seed funds**: Tiny VC, Hustle Fund, Kima (EU), Bynd VC (DACH)\n5. **Accelerators with funding**: YC ($500K for 7%), Antler, Entrepreneur First\n\n## Month 2-3: Active Fundraising\n- 20-30 first conversations (angels + pre-seed funds)\n- Iterate deck based on common questions\n- Get first commitment → use as social proof\n- Target: 2-3 lead investors who set terms\n\n## Month 3-4: Close\n- Term sheet → negotiate → sign in ≤2 weeks\n- Legal: SAFE or convertible note (€2K-€5K legal cost, not more)\n- Wire transfers + board resolution\n- Announce (optional): build momentum for next round\n\n## Common Mistakes\n- Raising too little (not enough runway to reach Series A milestones)\n- Giving up too much equity (>25% pre-seed = problematic for later rounds)\n- Spending too long on deck vs customers\n- Over-optimizing terms instead of closing\n- Raising from too many small angels (cap table mess)\n\n## Pre-Seed → Seed Milestones to Hit\n- €10-30K MRR (SaaS) OR 100K DAU (consumer)\n- Product-market fit signal (NPS >50 or 40%+ would be \"very disappointed\")\n- Repeatable customer acquisition channel identified\n- Team: 3-5 people covering product + growth + ops\n", "format": "markdown", "tags": "[\"pre_seed\", \"roadmap\", \"angels\", \"SAFE\", \"fundraising\"]", "source": "YC Library + Hustle Fund + Crunchbase data", "language": "en"}
{"category": "roadmap", "subcategory": "series_a", "region": "global", "stage": "series_a", "title": "Series A Fundraising Roadmap (€3M€20M)", "content": "# Series A Fundraising Roadmap\n\n## What Series A Investors Want\nThe #1 filter: **proof of scalable, repeatable customer acquisition**\n- SaaS: €500K-€1.5M ARR, growing >15% MoM, NRR >110%\n- Marketplace: GMV >€5M/year, take rate stabilizing\n- Consumer: 1M+ DAU, retention cohorts proven, LTV:CAC >3×\n\n## 6-Month Preparation Timeline\n\n### Months -6 to -4: Foundation\n- [ ] Hit \"Series A ready\" metrics (see above)\n- [ ] Identify lead VC (who leads, not follows)\n- [ ] Board/advisors: introductions to target VCs\n- [ ] Hire: head of sales OR head of growth (shows scaling)\n- [ ] Financial model: 18-month actual + 36-month projections\n\n### Months -3 to -2: Pre-marketing\n- [ ] Quiet conversations with 5-10 VCs (NOT pitches)\n- [ ] \"What would you need to see to invest?\" → reverse engineer\n- [ ] Build relationships 6 months before you need money\n- [ ] Get warm intros from your seed investors (their job)\n\n### Month -1: Prep\n- [ ] Finalize deck, model, data room\n- [ ] Board approval for raise\n- [ ] 409A valuation current (US) or share valuation (EU)\n- [ ] List of 30-50 target VCs, tiered A/B/C\n\n### Fundraising Sprint (6-8 weeks)\n- Week 1-2: 15-20 first meetings\n- Week 3-4: second meetings (product demo, team meet)\n- Week 5-6: partner meetings + references\n- Week 7-8: term sheets + negotiation\n- Goal: term sheet in hand within 8 weeks of first meeting\n\n## Series A Term Sheet Norms (2024)\n- Valuation: 6-12× ARR (depending on growth rate)\n- Round size: €5-15M\n- Dilution: 15-25% for lead investor\n- Board seat: 1 investor board member (lead)\n- Liquidation preference: 1× non-participating (standard)\n- Option pool: 10-15% post-money (replenish to this)\n- Pro-rata: yes for lead at Series B\n\n## Series A → Series B Milestones\n- €3-5M ARR (from €0.5-1.5M at A)\n- Repeatable sales process (quota-carrying reps hitting targets)\n- VP Sales or CRO hired\n- Gross margin >70% (SaaS)\n- <5% monthly churn\n- NRR >120%\n\n## Top Series A Investors (EU/DACH/UK)\n- Balderton Capital (pan-EU, €2-15M lead)\n- Index Ventures (pan-EU + US, €5-20M)\n- Northzone (pan-EU, €3-15M)\n- HV Capital (DACH focus, €5-30M)\n- Accel (pan-EU + US, €5-20M)\n- Atomico (pan-EU, later stage)\n- Sequoia Arc (pan-EU, €1-5M seed/A)\n- EQT Ventures (Nordic + EU, €5-30M)\n", "format": "markdown", "tags": "[\"series_a\", \"roadmap\", \"VC\", \"milestones\", \"fundraising\"]", "source": "a16z + Sequoia + Balderton data", "language": "en"}
{"category": "roadmap", "subcategory": "grants", "region": "eu", "stage": "all", "title": "EU Non-Dilutive Funding — Grants & Programs", "content": "# EU Non-Dilutive Funding Map\n\n## EIC Accelerator (European Innovation Council)\n- Amount: up to €2.5M grant + €15M equity investment\n- Equity: EIC takes 10-25% (optional, market rate)\n- For: deep tech, climate, health, digital\n- Eligibility: SME, <250 employees, innovative\n- Success rate: ~5% (very competitive)\n- Apply: eic.ec.europa.eu | Calls: continuous + cut-off dates\n- Process: online application → pitch → jury → EIC Board\n- Timeline: 6-12 months from application to funding\n\n## Horizon Europe — Collaborative R&D\n- Amount: €1M-€10M per project (consortium)\n- No equity taken\n- Required: consortium of 3+ EU entities\n- Overhead: 25% flat rate allowed\n- Instruments: RIA (Research & Innovation Action), IA, CSA\n- For: pre-commercial innovation, research + demonstration\n- Key calls: SME instrument, Fast Track to Innovation\n\n## InvestEU\n- EU guarantee fund backing EIB/EIF investments\n- Reaches startups via: VCs, banks, national development banks\n- Not direct application — through intermediaries\n\n## ERDF / Structural Funds (Country-level)\n- Managed by national/regional authorities\n- PNRR (RO): €14.9B total, startup + digitalization components\n- Digital Germany (DE): multiple federal programs\n- Innovate UK (UK, post-Brexit): now independent of EU\n\n## Quick Grant Map by Stage\n| Stage | Program | Amount |\n|-------|---------|--------|\n| R&D proof of concept | EIC Pathfinder | €150K-€3M |\n| MVP validation | EIC Transition | €2.5M |\n| Scale-up | EIC Accelerator | €2.5M grant + €15M equity |\n| Deep collaboration | Horizon Europe | €1M-€10M |\n| Export to EU markets | COSME/EISMEA | €50K-€200K |\n\n## Application Tips\n- Hire a grant writer for EIC (ROI positive if you're competitive)\n- Evaluation criteria: Excellence (Impact + Scientific Merit) + Execution\n- SME instrument: solo applications possible (unlike main Horizon)\n- Letter of support from customers/partners = strong signal\n- Budget: be realistic, assessors check market rates\n", "format": "markdown", "tags": "[\"grants\", \"EU\", \"EIC\", \"Horizon_Europe\", \"non_dilutive\", \"roadmap\"]", "source": "EIC.ec.europa.eu + eufunding.guide", "language": "en"}
{"category": "investor", "subcategory": "criteria", "region": "global", "stage": "all", "title": "Investor Types — Criteria & Approach Matrix", "content": "# Investor Type Comparison Matrix\n\n## Angel Investors\n- **Check size:** €5K€500K\n- **Decision speed:** 1-4 weeks (fastest)\n- **DD depth:** light (mostly founder conviction)\n- **Value-add:** intros, domain expertise, morale\n- **Downsides:** less follow-on capital, time-poor\n- **How to approach:** warm intro mandatory for most\n- **Best for:** pre-seed, founder-market fit validation\n- **Finding them:** AngelList, LinkedIn, industry events, YC alumni network\n\n## Micro VCs (€10M€50M fund size)\n- **Check size:** €100K€1M\n- **Decision speed:** 2-6 weeks\n- **DD depth:** medium (founder-focused, light financials)\n- **Value-add:** portfolio network, co-investors\n- **EU examples:** Kima (FR), Tiny VC (UK), Ada VC, Adevinta Ventures\n- **Best for:** pre-seed, unconventional founders\n\n## Seed VCs (€50M€200M fund size)\n- **Check size:** €500K€3M\n- **Decision speed:** 4-8 weeks\n- **DD depth:** medium-heavy\n- **Ownership target:** 10-20% per investment\n- **EU examples:** Cherry (DE), Cavalry (DE), Notion Capital (UK), Seedcamp (UK)\n- **Best for:** post-PMF, first revenue signal\n\n## Series A/B VCs (>€200M fund size)\n- **Check size:** €3M€25M (lead)\n- **Decision speed:** 8-16 weeks\n- **DD depth:** full (legal, financial, technical, customer refs)\n- **Ownership target:** 15-25%\n- **Board seat:** always (lead)\n- **EU examples:** Balderton, Index, Accel, Northzone, HV Capital\n- **Best for:** proven PMF, scaling go-to-market\n\n## Corporate Venture Capital (CVC)\n- **Check size:** €500K€20M\n- **Decision speed:** 3-6 months (slowest)\n- **Motivation:** strategic, not purely financial\n- **Pros:** customer + distribution access, patient capital\n- **Cons:** conflicts with strategic acquirer role, slow process\n- **EU examples:** Bosch Ventures, Siemens Ventures, Deutsche Telekom, BMW iVentures\n- **Red flag:** exclusivity or ROFR clauses (reject)\n\n## Family Offices\n- **Check size:** €1M€20M\n- **Decision speed:** 2-8 weeks (relationship-dependent)\n- **Motivation:** wealth preservation + diversification\n- **Pros:** less pressure for exit, can be patient\n- **Finding:** EuropeanFamilyOffices.com, TIGER 21, family office conferences\n\n## Accelerators with Funding\n| Program | Funding | Equity | Location |\n|---------|---------|--------|----------|\n| YC | $500K | 7% | Global |\n| Techstars | $120K | 6% | Global |\n| Antler | €100K | 10% | EU+Global |\n| Entrepreneur First | varies | ~5% | UK/EU/SG |\n| Station F | non-dilutive | 0% | France |\n| APX (Porsche+Axel Springer) | €50K | 5% | Berlin |\n", "format": "markdown", "tags": "[\"investor_types\", \"angels\", \"VC\", \"CVC\", \"criteria\", \"comparison\"]", "source": "Pitchbook + CB Insights + Dealroom data", "language": "en"}
{"category": "legal", "subcategory": "esop", "region": "global", "stage": "all", "title": "ESOP — Employee Stock Option Plan Design", "content": "# Employee Stock Option Plan (ESOP) Design\n\n## Why ESOP Matters for Fundraising\n- Option pool size = dilution to existing shareholders\n- VCs require: 10-15% unallocated option pool POST-money\n- Created pre-money (founders bear dilution, not investors)\n- Missing ESOP = red flag for talent retention\n\n## Standard Vesting Schedule\n- **4-year vesting with 1-year cliff** (global standard)\n- Month 0-12: 0% vested\n- Month 12: 25% cliff vests immediately\n- Month 13-48: 1/36th per month\n- **Acceleration:** single-trigger (acquisition) or double-trigger (acquisition + termination)\n\n## Strike Price\n- **Germany:** \"Fair market value\" at grant date (Bewertungsgutachten)\n- **US:** 409A valuation (independent appraisal required, valid 12 months)\n- **UK:** EMI (Enterprise Management Incentive): HMRC-approved, CGT treatment\n- **France:** BSPCE (Bons de Souscription de Parts de Créateur d'Entreprise): favorable\n\n## German-Specific ESOP (GmbH)\n- **Problem:** GmbH shares (Geschäftsanteile) cannot be fractional\n- **Solution 1:** Virtual Stock Options (VSOPs) — phantom equity, paid cash\n- **Solution 2:** Convert to GmbH & Co. KG or UG hybrid\n- **Solution 3:** Convert to AG before ESOP\n- **Most common startup approach:** VSOP with conversion rights\n\n### VSOP Terms (Germany)\n- Exit participation: % of exit proceeds, not actual shares\n- Tax on VSOP payout: income tax (not capital gains) = up to 47%\n- After 2021 reform: deferred taxation possible (§19a EStG)\n- Better for high earners: convert to AG + issue real options\n\n## UK EMI (Enterprise Management Incentive)\n- Most tax-efficient UK structure: CGT rate on exercise (20%) not income tax\n- Company eligible: <250 employees, <£30M assets, qualifying trade\n- Employee limit: up to £250K options per employee (value at grant)\n- HMRC approval required: 3-4 weeks\n- Exercise window: 10 years from grant\n\n## Good Leaver / Bad Leaver\n- **Good leaver** (resignation, health, redundancy): keeps vested options, may keep unvested (negotiated)\n- **Bad leaver** (misconduct, cause): loses all options, vested shares at cost price\n- Define clearly in plan rules — major source of startup litigation\n\n## Typical Allocations\n- CTO/COO/VP Engineering: 1-3%\n- Early engineers (pre-Series A): 0.1-0.5%\n- Post-Series A senior hire: 0.05-0.2%\n- Advisors: 0.1-0.25% (with vesting)\n", "format": "markdown", "tags": "[\"ESOP\", \"options\", \"vesting\", \"equity\", \"legal\", \"compensation\"]", "source": "Leapsome + Capdesk + Index Ventures Option Plan", "language": "en"}
{"category": "legal", "subcategory": "sha_guide", "region": "global", "stage": "series_a", "title": "SHA Key Terms — Shareholder Agreement Guide", "content": "# Shareholder Agreement (SHA) — Key Terms for Founders\n\n## Protective Provisions (Investor Veto Rights)\nInvestors typically require approval for:\n- Issuing new shares (equity rounds)\n- Changes to company articles\n- Sale of major assets\n- Related-party transactions >€[threshold]\n- Major acquisitions\n- CEO change\n\n**Founder goal:** keep list SHORT and specific. Broad protective provisions = loss of control.\n\n## Drag-Along Right\n- **What:** majority can force minority to sell in M&A\n- **Why it matters:** prevents minority blocking exits\n- **Standard:** triggered by [X]% of shareholders (typically 75%)\n- **Founder protection:** ensure you're in the dragging majority\n\n## Tag-Along Right\n- **What:** if founders sell, investors can sell pro-rata too\n- **Standard:** yes, include for all investors\n- **Threshold:** usually >5% stake triggers tag-along\n\n## Right of First Refusal (ROFR) / Right of First Offer (ROFO)\n- **ROFR:** company/existing shareholders get right to match third-party offer\n- **ROFO:** must offer to existing shareholders first before going to market\n- **Typical:** 30-day ROFR window\n- **Watch:** transfer restrictions can make secondary sales nearly impossible\n\n## Information Rights\n- **Board level:** monthly P&L, bank statements, KPIs\n- **Investor level:** quarterly financial statements + annual audited accounts\n- **Threshold:** usually for investors holding >1-2%\n\n## Anti-Dilution (Recap)\n| Type | Formula | Impact |\n|------|---------|--------|\n| Broad-based WA | New price weighted by ALL shares | Moderate dilution protection |\n| Narrow-based WA | Weighted by preferred only | More protection for investors |\n| Full ratchet | Price reset to any lower price | Extreme, avoid |\n\n## Founder Protections to Negotiate\n- **Veto on own removal as CEO:** yes if pre-Series B\n- **Co-sale right in secondary:** if investors sell, founders can too\n- **Good leaver provisions:** defined and fair\n- **IP ownership:** ensure company owns it, not individual\n- **Non-compete scope:** limit geography and duration (max 2 years post-departure)\n\n## Exit Provisions\n- **Exit preference:** 1× non-participating (standard post-2020)\n- **IPO trigger:** majority can force IPO after [date]\n- **Drag-along threshold:** 75-85% to protect minority\n", "format": "markdown", "tags": "[\"SHA\", \"shareholder_agreement\", \"legal\", \"protective_provisions\", \"series_a\"]", "source": "BVCA + Startup Law Berlin + SeedLegals", "language": "en"}
{"category": "metric", "subcategory": "benchmarks", "region": "global", "stage": "all", "title": "Fundraising KPI Benchmarks by Stage", "content": "# KPI Benchmarks for Fundraising (2024)\n\n## SaaS Benchmarks\n\n| Metric | Pre-Seed | Seed | Series A | Series B |\n|--------|----------|------|----------|----------|\n| MRR | <€10K | €10-50K | €50-150K | €200K-1M |\n| ARR | <€120K | €120K-600K | €600K-2M | €2M-12M |\n| MoM Growth | N/A | >15% | >10% | >8% |\n| Gross Margin | >60% | >65% | >70% | >72% |\n| NRR | N/A | >100% | >110% | >120% |\n| Logo Churn | <5%/mo | <3%/mo | <2%/mo | <1.5%/mo |\n| CAC Payback | <24mo | <18mo | <15mo | <12mo |\n| LTV:CAC | >2× | >3× | >3× | >4× |\n| Burn Multiple | <3× | <2× | <1.5× | <1× |\n\n## Valuation Multiples (2024 reset from 2021 peak)\n| Stage | ARR Multiple | Notes |\n|-------|-------------|-------|\n| Pre-seed | N/A (milestone-based) | |\n| Seed | 15-25× ARR | if growing >30% MoM |\n| Series A | 8-15× ARR | if growing >15% MoM |\n| Series B | 6-10× ARR | if NRR >120% |\n| Series C+ | 4-8× ARR | profitability expected soon |\n\n## Rule of 40 (Series B+)\n`Revenue Growth % + EBITDA Margin % ≥ 40`\n- 50+: excellent\n- 40-50: strong\n- <40: needs explanation\n\n## Burn Rate & Runway\n- Minimum runway when fundraising: 6 months\n- Optimal start: 9-12 months runway\n- Target close: 18-24 months runway post-close\n- Burn multiple = net burn ÷ net new ARR (target: <1.5× at Series A)\n\n## Investor Outreach Benchmarks\n| Metric | Typical | Good |\n|--------|---------|------|\n| Cold email response rate | 2-5% | >10% |\n| Meeting → second meeting | 30-40% | >50% |\n| Second → partner meeting | 20-30% | >40% |\n| Partner → term sheet | 10-20% | >30% |\n| Meetings needed for one TS | 40-80 | <30 |\n\n## Market Size Minimums\n- VC (any stage): TAM >€1B minimum\n- Series A lead VC: TAM >€10B preferred\n- Formula: TAM = (# customers) × (ACV per customer)\n- Mistake: using top-down only — always validate bottom-up\n\n## Unit Economics Quick Reference\n- **CAC** = total sales + marketing spend ÷ new customers acquired\n- **LTV** = (ARPU × gross margin %) ÷ churn rate\n- **CAC Payback** = CAC ÷ (ARPU × gross margin %)\n- **Payback benchmark:** <12 months for venture-scale SaaS\n", "format": "markdown", "tags": "[\"metrics\", \"KPI\", \"benchmarks\", \"ARR\", \"LTV\", \"CAC\", \"valuation\"]", "source": "Bessemer Venture Partners State of the Cloud + OpenView SaaS Benchmarks", "language": "en"}
{"category": "template", "subcategory": "financial_model", "region": "global", "stage": "all", "title": "Financial Model Structure — 3-Year Forecast", "content": "# Financial Model Template (SaaS / Startup)\n\n## Sheet Structure\n1. **Assumptions** (input sheet — all variables here)\n2. **Revenue Model** (by product line / segment)\n3. **P&L** (monthly Y1, quarterly Y2-Y3)\n4. **Cash Flow** (direct method)\n5. **Balance Sheet** (simplified)\n6. **KPI Dashboard** (for investor view)\n7. **Scenarios** (conservative / base / aggressive)\n\n## Revenue Model — SaaS\n```\nNew Logos × ACV = New ARR\n+ Beginning ARR\n- Churned ARR (beginning ARR × annual churn %)\n+ Expansion ARR (existing customers × expansion rate)\n= Ending ARR ÷ 12 = MRR\n```\n\nKey assumptions to document:\n- Sales cycle length (months)\n- Lead → opportunity conversion %\n- Opportunity → close conversion %\n- Average ACV by segment\n- Net Revenue Retention (NRR) %\n- Gross churn rate\n\n## P&L Structure\n```\nRevenue\n- COGS (hosting, support, CS, payments)\n= Gross Profit [Gross Margin %]\n\n- R&D (product + engineering salaries)\n- Sales & Marketing (AE + SDR + marketing)\n- G&A (finance, legal, HR, office)\n= EBITDA [EBITDA Margin %]\n\n- Depreciation & Amortization\n= EBIT\n\n- Interest expense\n= EBT\n\n- Tax\n= Net Income\n```\n\n## Headcount Plan (biggest cost driver)\n- List every hire by month + role + fully loaded cost\n- Fully loaded = salary × 1.25-1.35 (benefits, employer NI/social, equipment)\n- Germany: +25-30% on top of gross salary\n- Include: backfill risk, ramp time (50% productivity for 3 months)\n\n## Investor Presentation: Key Metrics to Highlight\n- ARR waterfall (beginning + new + expansion - churn = ending)\n- MoM growth rate trend\n- Burn vs ARR growth (burn multiple)\n- Headcount plan vs revenue per employee\n- CAC payback period trend\n\n## Common Mistakes in Financial Models\n1. Revenue too optimistic in Y2-3 (use 50% of what feels \"conservative\")\n2. Missing hiring ramp — people don't produce day 1\n3. Ignoring seasonality (B2B: Q4 strong, Q1 slow)\n4. COGS too low (underestimate CS team, hosting at scale)\n5. Tax rate = 0 (even with losses: deferred tax assets)\n6. Not showing how funding runs out (investors want to see need)\n", "format": "markdown", "tags": "[\"financial_model\", \"forecast\", \"P&L\", \"SaaS\", \"template\"]", "source": "Christoph Janz SaaS metrics + OpenView benchmarks", "language": "en"}

28
money-raise/schema.sql Normal file
View file

@ -0,0 +1,28 @@
-- Money Raise Knowledge Base
-- Run: docker exec <ch-container> clickhouse-client < schema.sql
CREATE DATABASE IF NOT EXISTS money_raise;
CREATE TABLE IF NOT EXISTS money_raise.knowledge_base
(
id UUID DEFAULT generateUUIDv4(),
category LowCardinality(String), -- template|standard|protocol|roadmap|investor|legal|metric|region
subcategory LowCardinality(String),
region LowCardinality(String), -- global|us|eu|dach|uk|ro|mena|apac
stage LowCardinality(String), -- pre_seed|seed|series_a|series_b|growth|all
title String,
content String,
format LowCardinality(String), -- markdown|checklist|json|template
tags Array(String),
source String,
language LowCardinality(String), -- en|ro|de
created_at DateTime DEFAULT now()
)
ENGINE = MergeTree()
ORDER BY (category, subcategory, region, created_at)
PARTITION BY category
SETTINGS index_granularity = 8192;
-- Full-text search index
ALTER TABLE money_raise.knowledge_base
ADD INDEX idx_content content TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1;

View file

@ -0,0 +1,223 @@
{
"name": "CEO OS — Daily Planner",
"nodes": [
{
"id": "n2-trigger",
"name": "Every Day 7:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [
0,
300
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 7 * * 1-5"
}
]
}
}
},
{
"id": "n2-vars",
"name": "Config",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
220,
300
],
"parameters": {
"mode": "manual",
"assignments": {
"assignments": [
{
"id": "b1",
"name": "api",
"value": "={{$env['CEO_OS_API_URL'] ?? 'https://boardmind.dev/api'}}",
"type": "string"
},
{
"id": "b2",
"name": "tenantId",
"value": "={{$env['CEO_OS_TENANT_ID']}}",
"type": "string"
},
{
"id": "b3",
"name": "serviceKey",
"value": "={{$env['CEO_OS_SERVICE_KEY']}}",
"type": "string"
}
]
}
}
},
{
"id": "n2-daily-brief",
"name": "Get Daily Brief",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
440,
200
],
"parameters": {
"url": "={{$json.api}}/v1/ai/daily-brief",
"method": "GET",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$json.serviceKey}}"
}
]
}
}
},
{
"id": "n2-get-tasks",
"name": "Get Active Tasks",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
440,
400
],
"parameters": {
"url": "={{$('Config').item.json.api}}/v1/tasks?tenantId={{$('Config').item.json.tenantId}}&status=active",
"method": "GET",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$('Config').item.json.serviceKey}}"
}
]
}
}
},
{
"id": "n2-build-plan",
"name": "Build Daily Plan",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "const cfg = $('Config').item.json;\nconst brief = $('Get Daily Brief').first()?.json?.brief ?? '';\nconst tasks = $('Get Active Tasks').all().map(i => i.json);\n\n// Sort by priority tags\nconst priority = tasks.filter(t => (t.tags || []).some(g => ['urgent','important','high'].includes(g)));\nconst normal = tasks.filter(t => !priority.find(p => p.id === t.id));\n\nconst plan = [\n `📅 Planul zilei — ${new Date().toLocaleDateString('ro-RO', {weekday:'long', day:'numeric', month:'long'})}`,\n '',\n '### AI Brief',\n brief,\n '',\n `### ⚡ Prioritare (${priority.length})`,\n ...priority.slice(0, 5).map((t,i) => `${i+1}. ${t.title}`),\n '',\n `### 📋 Normale (${normal.length})`,\n ...normal.slice(0, 10).map(t => `• ${t.title}`),\n].join('\\n');\n\nreturn [{ json: {\n plan,\n totalTasks: tasks.length,\n priorityCount: priority.length,\n api: cfg.api, tenantId: cfg.tenantId, serviceKey: cfg.serviceKey,\n}}];"
}
},
{
"id": "n2-save",
"name": "Save Daily Plan",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
900,
300
],
"parameters": {
"url": "={{$json.api}}/v1/observations",
"method": "POST",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$json.serviceKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"tenantId\": \"{{$json.tenantId}}\", \"metric\": \"daily-plan\", \"value\": {{JSON.stringify($json.plan)}}, \"unit\": \"{{$json.totalTasks}} tasks\", \"subjectType\": \"planning\", \"confidence\": 1}"
}
}
],
"connections": {
"Every Day 7:00": {
"main": [
[
{
"node": "Config",
"type": "main",
"index": 0
}
]
]
},
"Config": {
"main": [
[
{
"node": "Get Daily Brief",
"type": "main",
"index": 0
},
{
"node": "Get Active Tasks",
"type": "main",
"index": 0
}
]
]
},
"Get Daily Brief": {
"main": [
[
{
"node": "Build Daily Plan",
"type": "main",
"index": 0
}
]
]
},
"Get Active Tasks": {
"main": [
[
{
"node": "Build Daily Plan",
"type": "main",
"index": 0
}
]
]
},
"Build Daily Plan": {
"main": [
[
{
"node": "Save Daily Plan",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"tags": [
{
"name": "ceo-os"
},
{
"name": "automation"
}
]
}

View file

@ -0,0 +1,264 @@
{
"name": "CEO OS — Financial Weekly Brief",
"nodes": [
{
"id": "n4-trigger",
"name": "Every Friday 17:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [
0,
300
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 17 * * 5"
}
]
}
}
},
{
"id": "n4-vars",
"name": "Config",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
220,
300
],
"parameters": {
"mode": "manual",
"assignments": {
"assignments": [
{
"id": "d1",
"name": "api",
"value": "={{$env['CEO_OS_API_URL'] ?? 'https://boardmind.dev/api'}}",
"type": "string"
},
{
"id": "d2",
"name": "tenantId",
"value": "={{$env['CEO_OS_TENANT_ID']}}",
"type": "string"
},
{
"id": "d3",
"name": "serviceKey",
"value": "={{$env['CEO_OS_SERVICE_KEY']}}",
"type": "string"
}
]
}
}
},
{
"id": "n4-contracts",
"name": "Get Contracts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
440,
200
],
"parameters": {
"url": "={{$json.api}}/v1/contracts?tenantId={{$json.tenantId}}",
"method": "GET",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$json.serviceKey}}"
}
]
}
}
},
{
"id": "n4-obs",
"name": "Get Expense Observations",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
440,
400
],
"parameters": {
"url": "={{$('Config').item.json.api}}/v1/observations?tenantId={{$('Config').item.json.tenantId}}&subjectType=expense",
"method": "GET",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$('Config').item.json.serviceKey}}"
}
]
}
}
},
{
"id": "n4-build",
"name": "Build Financial Context",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "const cfg = $('Config').item.json;\nconst contracts = $('Get Contracts').all().map(i => i.json);\nconst expenses = $('Get Expense Observations').all().map(i => i.json);\n\nconst now = Date.now();\nconst monthAgo = now - 30 * 86400000;\n\nconst activeContracts = contracts.filter(c => c.status === 'signed' || c.status === 'active');\nconst totalRevenue = activeContracts.reduce((s, c) => s + (parseFloat(c.value) || 0), 0);\n\nconst monthlyExpenses = expenses.filter(e => new Date(e.observedAt || e.createdAt).getTime() > monthAgo);\nconst totalExpenses = monthlyExpenses.reduce((s, e) => s + (parseFloat(e.value) || 0), 0);\n\nconst prompt = [\n `Generează un brief financiar săptămânal. Vineri, ${new Date().toLocaleDateString('ro-RO')}.`,\n '',\n `## Date financiare:`,\n `- Contracte active: ${activeContracts.length} → Total valoare: ${totalRevenue.toLocaleString('ro-RO')} EUR`,\n `- Cheltuieli luna curentă: ${totalExpenses.toLocaleString('ro-RO')} EUR (${monthlyExpenses.length} tranzacții)`,\n `- Sold net estimat: ${(totalRevenue - totalExpenses).toLocaleString('ro-RO')} EUR`,\n '',\n 'Structurează în: 💰 Situație actuală, 📈 Tendință, ⚠️ Atenție la, ✅ Recomandate. Max 200 cuvinte.',\n].join('\\n');\n\nreturn [{ json: {\n prompt, totalRevenue, totalExpenses,\n api: cfg.api, tenantId: cfg.tenantId, serviceKey: cfg.serviceKey,\n}}];"
}
},
{
"id": "n4-ai",
"name": "Generate Financial Brief",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
900,
300
],
"parameters": {
"url": "={{$json.api}}/v1/ai/ask",
"method": "POST",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$json.serviceKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"messages\": [{\"role\": \"user\", \"content\": {{JSON.stringify($json.prompt)}}}], \"context\": \"{\\\"financialBrief\\\": true}\"}"
}
},
{
"id": "n4-save",
"name": "Save Financial Brief",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
1120,
300
],
"parameters": {
"url": "={{$('Build Financial Context').item.json.api}}/v1/observations",
"method": "POST",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$('Build Financial Context').item.json.serviceKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"tenantId\": \"{{$('Build Financial Context').item.json.tenantId}}\", \"metric\": \"financial-brief\", \"value\": {{JSON.stringify($json.reply)}}, \"unit\": \"{{$('Build Financial Context').item.json.totalRevenue}} EUR\", \"subjectType\": \"finance\", \"confidence\": 0.95}"
}
}
],
"connections": {
"Every Friday 17:00": {
"main": [
[
{
"node": "Config",
"type": "main",
"index": 0
}
]
]
},
"Config": {
"main": [
[
{
"node": "Get Contracts",
"type": "main",
"index": 0
},
{
"node": "Get Expense Observations",
"type": "main",
"index": 0
}
]
]
},
"Get Contracts": {
"main": [
[
{
"node": "Build Financial Context",
"type": "main",
"index": 0
}
]
]
},
"Get Expense Observations": {
"main": [
[
{
"node": "Build Financial Context",
"type": "main",
"index": 0
}
]
]
},
"Build Financial Context": {
"main": [
[
{
"node": "Generate Financial Brief",
"type": "main",
"index": 0
}
]
]
},
"Generate Financial Brief": {
"main": [
[
{
"node": "Save Financial Brief",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"tags": [
{
"name": "ceo-os"
},
{
"name": "finance"
}
]
}

View file

@ -0,0 +1,265 @@
{
"name": "CEO OS — Gmail Bridge",
"nodes": [
{
"id": "n3-trigger",
"name": "New Important Email",
"type": "n8n-nodes-base.gmailTrigger",
"typeVersion": 1,
"position": [
0,
300
],
"parameters": {
"filters": {
"labelIds": [
"INBOX",
"IMPORTANT"
]
},
"pollTimes": {
"item": [
{
"mode": "everyMinutes",
"value": 15
}
]
}
}
},
{
"id": "n3-filter",
"name": "Skip Auto-Generated",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
300
],
"parameters": {
"jsCode": "const email = $input.first().json;\nconst skipPatterns = ['noreply', 'no-reply', 'donotreply', 'unsubscribe', 'newsletter', 'automated'];\nconst from = (email.from || '').toLowerCase();\nconst subject = (email.subject || '').toLowerCase();\n\nconst isAuto = skipPatterns.some(p => from.includes(p) || subject.includes(p));\nif (isAuto) return []; // Drop auto-generated emails\n\nreturn [{ json: {\n emailId: email.id,\n from: email.from,\n subject: email.subject,\n snippet: email.snippet || email.body?.slice(0, 500),\n date: email.date,\n api: $env['CEO_OS_API_URL'] ?? 'https://boardmind.dev/api',\n tenantId: $env['CEO_OS_TENANT_ID'],\n serviceKey: $env['CEO_OS_SERVICE_KEY'],\n}}];"
}
},
{
"id": "n3-classify",
"name": "Classify Email with AI",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
440,
300
],
"parameters": {
"url": "={{$json.api}}/v1/ai/ask",
"method": "POST",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$json.serviceKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"messages\": [{\"role\": \"user\", \"content\": \"Clasifică emailul următor. Răspunde DOAR cu JSON: {\\\"action\\\": \\\"action_required|info_only|skip\\\", \\\"priority\\\": \\\"high|medium|low\\\", \\\"taskTitle\\\": \\\"titlu task dacă e action_required, altfel null\\\", \\\"summary\\\": \\\"1 propoziție rezumat\\\"}\\n\\nDe la: {{$json.from}}\\nSubiect: {{$json.subject}}\\nConținut: {{$json.snippet}}\"}], \"context\": \"{\\\"emailClassification\\\": true}\"}"
}
},
{
"id": "n3-parse",
"name": "Parse Classification",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
300
],
"parameters": {
"jsCode": "const reply = $input.first().json.reply || '';\nlet classification = { action: 'info_only', priority: 'low', taskTitle: null, summary: reply };\ntry {\n const jsonMatch = reply.match(/\\{[^}]+\\}/s);\n if (jsonMatch) classification = JSON.parse(jsonMatch[0]);\n} catch {}\n\nconst prev = $('Skip Auto-Generated').first().json;\nreturn [{ json: { ...prev, ...classification }}];"
}
},
{
"id": "n3-check-action",
"name": "Action Required?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
880,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "c1",
"leftValue": "={{$json.action}}",
"rightValue": "action_required",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
}
}
},
{
"id": "n3-create-task",
"name": "Create Task",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
1100,
200
],
"parameters": {
"url": "={{$json.api}}/v1/tasks",
"method": "POST",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$json.serviceKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"tenantId\": \"{{$json.tenantId}}\", \"title\": \"{{$json.taskTitle || $json.subject}}\", \"description\": \"📧 Email de la {{$json.from}}\\n\\n{{$json.summary}}\", \"tags\": [\"email\", \"{{$json.priority}}\"], \"priority\": \"{{$json.priority}}\"}"
}
},
{
"id": "n3-log-obs",
"name": "Log Email Observation",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
1100,
400
],
"parameters": {
"url": "={{$json.api}}/v1/observations",
"method": "POST",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$json.serviceKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"tenantId\": \"{{$json.tenantId}}\", \"metric\": \"email-received\", \"value\": \"{{$json.subject}}\", \"unit\": \"{{$json.action}}\", \"subjectType\": \"communication\", \"source\": \"{{$json.from}}\", \"confidence\": 0.9}"
}
}
],
"connections": {
"New Important Email": {
"main": [
[
{
"node": "Skip Auto-Generated",
"type": "main",
"index": 0
}
]
]
},
"Skip Auto-Generated": {
"main": [
[
{
"node": "Classify Email with AI",
"type": "main",
"index": 0
}
]
]
},
"Classify Email with AI": {
"main": [
[
{
"node": "Parse Classification",
"type": "main",
"index": 0
}
]
]
},
"Parse Classification": {
"main": [
[
{
"node": "Action Required?",
"type": "main",
"index": 0
}
]
]
},
"Action Required?": {
"main": [
[
{
"node": "Create Task",
"type": "main",
"index": 0
}
],
[
{
"node": "Log Email Observation",
"type": "main",
"index": 0
}
]
]
},
"Create Task": {
"main": [
[
{
"node": "Log Email Observation",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"tags": [
{
"name": "ceo-os"
},
{
"name": "gmail"
}
]
}

View file

@ -0,0 +1,270 @@
{
"name": "CEO OS — Weekly Review Generator",
"nodes": [
{
"id": "n1-trigger",
"name": "Every Monday 8:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [
0,
300
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 8 * * 1"
}
]
}
}
},
{
"id": "n1-vars",
"name": "Config",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
220,
300
],
"parameters": {
"mode": "manual",
"assignments": {
"assignments": [
{
"id": "a1",
"name": "api",
"value": "={{$env['CEO_OS_API_URL'] ?? 'https://boardmind.dev/api'}}",
"type": "string"
},
{
"id": "a2",
"name": "tenantId",
"value": "={{$env['CEO_OS_TENANT_ID']}}",
"type": "string"
},
{
"id": "a3",
"name": "serviceKey",
"value": "={{$env['CEO_OS_SERVICE_KEY']}}",
"type": "string"
},
{
"id": "a4",
"name": "weekAgo",
"value": "={{DateTime.now().minus({days: 7}).toISO()}}",
"type": "string"
}
]
}
}
},
{
"id": "n1-get-tasks",
"name": "Get Completed Tasks",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
440,
200
],
"parameters": {
"url": "={{$json.api}}/v1/tasks?tenantId={{$json.tenantId}}&status=completed",
"method": "GET",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$json.serviceKey}}"
}
]
}
}
},
{
"id": "n1-get-goals",
"name": "Get Active Goals",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
440,
400
],
"parameters": {
"url": "={{$('Config').item.json.api}}/v1/goals?tenantId={{$('Config').item.json.tenantId}}",
"method": "GET",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$('Config').item.json.serviceKey}}"
}
]
}
}
},
{
"id": "n1-build-prompt",
"name": "Build Review Prompt",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "const cfg = $('Config').item.json;\nconst tasks = $('Get Completed Tasks').all().map(i => i.json);\nconst goals = $('Get Active Goals').all().map(i => i.json);\n\nconst completedThisWeek = tasks.filter(t => {\n const d = new Date(t.completedAt || t.updatedAt || '');\n return Date.now() - d.getTime() < 7 * 86400000;\n});\n\nconst prompt = [\n `Generează o retrospectivă săptămânală pentru CEO OS. Data: ${new Date().toLocaleDateString('ro-RO')}.`,\n '',\n `## Task-uri finalizate săptămâna aceasta (${completedThisWeek.length}):`,\n ...completedThisWeek.slice(0, 20).map(t => `- ${t.title}`),\n '',\n `## Obiective active (${goals.length}):`,\n ...goals.slice(0, 10).map(g => `- ${g.title} [${g.progress ?? 0}%]`),\n '',\n 'Structurează răspunsul în: ✅ Ce am realizat, 🚀 Ce urmează, ⚠️ Blocaje, 💡 Lecții învățate. Max 300 cuvinte.',\n].join('\\n');\n\nreturn [{ json: {\n messages: [{ role: 'user', content: prompt }],\n context: JSON.stringify({ tenantId: cfg.tenantId, weeklyReview: true }),\n api: cfg.api,\n tenantId: cfg.tenantId,\n serviceKey: cfg.serviceKey,\n}}];"
}
},
{
"id": "n1-ai-ask",
"name": "Generate Review via Hermes",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
900,
300
],
"parameters": {
"url": "={{$json.api}}/v1/ai/ask",
"method": "POST",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$json.serviceKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"messages\": {{JSON.stringify($json.messages)}}, \"context\": {{JSON.stringify($json.context)}}}"
}
},
{
"id": "n1-save-obs",
"name": "Save Weekly Review",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
1120,
300
],
"parameters": {
"url": "={{$('Build Review Prompt').item.json.api}}/v1/observations",
"method": "POST",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Service-Key",
"value": "={{$('Build Review Prompt').item.json.serviceKey}}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"tenantId\": \"{{$('Build Review Prompt').item.json.tenantId}}\", \"metric\": \"weekly-review\", \"value\": {{JSON.stringify($json.reply)}}, \"subjectType\": \"review\", \"confidence\": 1}"
}
}
],
"connections": {
"Every Monday 8:00": {
"main": [
[
{
"node": "Config",
"type": "main",
"index": 0
}
]
]
},
"Config": {
"main": [
[
{
"node": "Get Completed Tasks",
"type": "main",
"index": 0
},
{
"node": "Get Active Goals",
"type": "main",
"index": 0
}
]
]
},
"Get Completed Tasks": {
"main": [
[
{
"node": "Build Review Prompt",
"type": "main",
"index": 0
}
]
]
},
"Get Active Goals": {
"main": [
[
{
"node": "Build Review Prompt",
"type": "main",
"index": 0
}
]
]
},
"Build Review Prompt": {
"main": [
[
{
"node": "Generate Review via Hermes",
"type": "main",
"index": 0
}
]
]
},
"Generate Review via Hermes": {
"main": [
[
{
"node": "Save Weekly Review",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"tags": [
{
"name": "ceo-os"
},
{
"name": "automation"
}
]
}

View file

@ -0,0 +1,91 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { actionPlans } from '../db/schema';
class CreateActionPlanDto {
@IsString() title!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsIn(['draft','active','completed','cancelled']) status?: string;
@IsOptional() @IsIn(['low','medium','high','critical']) priority?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsUUID() decisionId?: string;
@IsOptional() @IsUUID() goalId?: string;
@IsOptional() @IsUUID() projectId?: string;
}
class UpdateActionPlanDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsIn(['draft','active','completed','cancelled']) status?: string;
@IsOptional() @IsIn(['low','medium','high','critical']) priority?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() dueDate?: string;
}
@Controller('action-plans')
export class ActionPlansController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('status') status?: string,
) {
const tid = session.tenantId;
const where = status
? and(eq(actionPlans.tenantId, tid), isNull(actionPlans.deletedAt), eq(actionPlans.status, status))
: and(eq(actionPlans.tenantId, tid), isNull(actionPlans.deletedAt));
return db.query.actionPlans.findMany({ where, orderBy: [desc(actionPlans.createdAt)], limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateActionPlanDto) {
const [row] = await db.insert(actionPlans).values({
tenantId: session.tenantId,
title: dto.title,
description: dto.description,
status: dto.status ?? 'draft',
priority: dto.priority ?? 'medium',
owner: dto.owner,
dueDate: dto.dueDate ?? null,
decisionId: dto.decisionId ?? null,
goalId: dto.goalId ?? null,
projectId: dto.projectId ?? null,
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateActionPlanDto,
) {
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (dto.title !== undefined) updates.title = dto.title;
if (dto.description !== undefined) updates.description = dto.description;
if (dto.status !== undefined) {
updates.status = dto.status;
if (dto.status === 'completed') updates.completedAt = new Date();
}
if (dto.priority !== undefined) updates.priority = dto.priority;
if (dto.owner !== undefined) updates.owner = dto.owner;
if (dto.dueDate !== undefined) updates.dueDate = dto.dueDate;
const [row] = await db.update(actionPlans)
.set(updates)
.where(and(eq(actionPlans.id, id), eq(actionPlans.tenantId, session.tenantId)))
.returning();
return row;
}
@Delete(':id')
async remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.update(actionPlans)
.set({ deletedAt: new Date() })
.where(and(eq(actionPlans.id, id), eq(actionPlans.tenantId, session.tenantId)));
return { deleted: true };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ActionPlansController } from './action-plans.controller';
@Module({ controllers: [ActionPlansController] })
export class ActionPlansModule {}

View file

@ -0,0 +1,124 @@
import { Body, Controller, Get, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, sum, count } from 'drizzle-orm';
import { IsIn, IsOptional } from 'class-validator';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { aiRequests } from '../db/schema';
class UpdateAiRequestStatusDto {
@IsIn(['completed', 'cancelled']) resultStatus!: 'completed' | 'cancelled';
@IsOptional() @IsString() rejectionReason?: string;
}
@Controller('ai-requests')
export class AiRequestsController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('limit') limitStr?: string,
@Query('status') status?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '200', 10) || 200), 500);
const where = status
? and(eq(aiRequests.tenantId, session.tenantId), eq(aiRequests.resultStatus, status))
: eq(aiRequests.tenantId, session.tenantId);
return db.query.aiRequests.findMany({
where,
orderBy: desc(aiRequests.createdAt),
limit,
columns: {
contextManifest: false,
},
});
}
@Get('costs')
async costs(@CurrentSession() session: AuthenticatedSession) {
const [totals] = await db
.select({
totalCostUsd: sum(aiRequests.costUsd),
totalRequests: count(),
})
.from(aiRequests)
.where(eq(aiRequests.tenantId, session.tenantId));
const byModel = await db
.select({
model: aiRequests.model,
requests: count(),
costUsd: sum(aiRequests.costUsd),
})
.from(aiRequests)
.where(eq(aiRequests.tenantId, session.tenantId))
.groupBy(aiRequests.model);
const byPurpose = await db
.select({
purpose: aiRequests.purpose,
requests: count(),
costUsd: sum(aiRequests.costUsd),
})
.from(aiRequests)
.where(eq(aiRequests.tenantId, session.tenantId))
.groupBy(aiRequests.purpose);
const byStatus = await db
.select({
resultStatus: aiRequests.resultStatus,
requests: count(),
})
.from(aiRequests)
.where(eq(aiRequests.tenantId, session.tenantId))
.groupBy(aiRequests.resultStatus);
return {
totals: {
totalCostUsd: totals?.totalCostUsd ?? '0',
totalRequests: totals?.totalRequests ?? 0,
},
byModel,
byPurpose,
byStatus,
};
}
@Get(':id')
async getOne(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
) {
const record = await db.query.aiRequests.findFirst({
where: and(eq(aiRequests.id, id), eq(aiRequests.tenantId, session.tenantId)),
});
if (!record) {
throw new NotFoundException('AI request not found');
}
return record;
}
@Patch(':id/status')
async updateStatus(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAiRequestStatusDto,
) {
const record = await db.query.aiRequests.findFirst({
where: and(
eq(aiRequests.id, id),
eq(aiRequests.tenantId, session.tenantId),
),
columns: { id: true, resultStatus: true },
});
if (!record) throw new NotFoundException('AI request not found');
if (record.resultStatus !== 'pending') {
return { updated: false, reason: 'request is not pending' };
}
const [updated] = await db
.update(aiRequests)
.set({ resultStatus: dto.resultStatus, completedAt: new Date() })
.where(eq(aiRequests.id, id))
.returning({ id: aiRequests.id, resultStatus: aiRequests.resultStatus, completedAt: aiRequests.completedAt });
return { updated: true, record: updated };
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AiRequestsController } from './ai-requests.controller';
@Module({
controllers: [AiRequestsController],
})
export class AiRequestsModule {}

View file

@ -0,0 +1,82 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { assumptions } from '../db/schema';
class CreateAssumptionDto {
@IsString() statement!: string;
@IsOptional() @IsUUID() decisionId?: string;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsIn(['low','medium','high']) confidence?: string;
@IsOptional() @IsString() reviewDate?: string;
@IsOptional() @IsString() notes?: string;
}
class UpdateAssumptionDto {
@IsOptional() @IsString() statement?: string;
@IsOptional() @IsIn(['low','medium','high']) confidence?: string;
@IsOptional() @IsIn(['unverified','confirmed','refuted','pending_review']) status?: string;
@IsOptional() @IsString() evidenceUrl?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsString() reviewDate?: string;
}
@Controller('assumptions')
export class AssumptionsController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('decisionId') decisionId?: string,
@Query('status') status?: string,
) {
const tid = session.tenantId;
let where = eq(assumptions.tenantId, tid) as ReturnType<typeof eq>;
if (decisionId) where = and(where, eq(assumptions.decisionId, decisionId)) as typeof where;
if (status) where = and(where, eq(assumptions.status, status)) as typeof where;
return db.query.assumptions.findMany({ where, orderBy: [desc(assumptions.createdAt)], limit: 300 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateAssumptionDto) {
const [row] = await db.insert(assumptions).values({
tenantId: session.tenantId,
statement: dto.statement,
decisionId: dto.decisionId ?? null,
source: dto.source,
confidence: dto.confidence ?? 'medium',
reviewDate: dto.reviewDate ?? null,
notes: dto.notes,
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAssumptionDto,
) {
const upd: Record<string, unknown> = { updatedAt: new Date() };
if (dto.statement !== undefined) upd.statement = dto.statement;
if (dto.confidence !== undefined) upd.confidence = dto.confidence;
if (dto.status !== undefined) upd.status = dto.status;
if (dto.evidenceUrl !== undefined) upd.evidenceUrl = dto.evidenceUrl;
if (dto.notes !== undefined) upd.notes = dto.notes;
if (dto.reviewDate !== undefined) upd.reviewDate = dto.reviewDate;
const [row] = await db.update(assumptions)
.set(upd)
.where(and(eq(assumptions.id, id), eq(assumptions.tenantId, session.tenantId)))
.returning();
return row;
}
@Delete(':id')
async remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.delete(assumptions)
.where(and(eq(assumptions.id, id), eq(assumptions.tenantId, session.tenantId)));
return { deleted: true };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { AssumptionsController } from './assumptions.controller';
@Module({ controllers: [AssumptionsController] })
export class AssumptionsModule {}

View file

@ -0,0 +1,22 @@
import { Controller, Get, Query } from '@nestjs/common';
import { desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { auditLog } from '../db/schema';
@Controller('audit-log')
export class AuditLogController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('limit') limitStr?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '100', 10) || 100), 500);
return db.query.auditLog.findMany({
where: eq(auditLog.tenantId, session.tenantId),
orderBy: desc(auditLog.createdAt),
limit,
});
}
}

View file

@ -1,9 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { AuditLogController } from './audit.controller';
import { AuditService } from './audit.service';
// Global: aproape fiecare modul de domeniu scrie audit; evitam importuri repetate.
@Global()
@Module({
controllers: [AuditLogController],
providers: [AuditService],
exports: [AuditService],
})

View file

@ -1,14 +1,123 @@
import { Controller, Get } from '@nestjs/common';
import { and, desc, eq, gt, gte, isNull, isNotNull, lt, lte, inArray, or } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { BriefingService } from './briefing.service';
import { db } from '../db/client';
import { goals, tasks, decisions, opportunities, financialSignals, notifications } from '../db/schema';
@Controller('briefing')
export class BriefingController {
constructor(private readonly briefingService: BriefingService) {}
@Get()
async today(@CurrentSession() session: AuthenticatedSession) {
const now = new Date();
const todayEnd = new Date(now);
todayEnd.setHours(23, 59, 59, 999);
const sevenDays = new Date(now.getTime() + 7 * 86400_000);
const tid = session.tenantId;
@Get('today')
today(@CurrentSession() session: AuthenticatedSession) {
return this.briefingService.today(session);
const [
atRiskGoals,
overdueTasks,
todayTasks,
pendingDecisions,
criticalSignals,
expiringOpps,
recentNotifications,
] = await Promise.all([
// Goals at risk
db.query.goals.findMany({
where: and(eq(goals.tenantId, tid), isNull(goals.deletedAt), eq(goals.status, 'at_risk')),
orderBy: [desc(goals.createdAt)],
limit: 5,
}),
// Overdue tasks
db.query.tasks.findMany({
where: and(
eq(tasks.tenantId, tid),
isNull(tasks.deletedAt),
inArray(tasks.status, ['open', 'in_progress', 'blocked'] as any[]),
lt(tasks.dueAt, now),
),
orderBy: [desc(tasks.priority)],
limit: 10,
}),
// Tasks due today
db.query.tasks.findMany({
where: and(
eq(tasks.tenantId, tid),
isNull(tasks.deletedAt),
inArray(tasks.status, ['open', 'in_progress'] as any[]),
gte(tasks.dueAt, now),
lte(tasks.dueAt, todayEnd),
),
orderBy: [desc(tasks.priority)],
limit: 10,
}),
// Decisions pending outcome review
db.query.decisions.findMany({
where: and(
eq(decisions.tenantId, tid),
isNotNull(decisions.reviewDueAt),
lte(decisions.reviewDueAt, now),
isNull(decisions.outcomeReview),
),
orderBy: [desc(decisions.reviewDueAt)],
limit: 5,
}),
// Critical financial signals (last 48h)
db.query.financialSignals.findMany({
where: and(
or(isNull(financialSignals.tenantId), eq(financialSignals.tenantId, tid)),
eq(financialSignals.severity, 'critical'),
gte(financialSignals.publishedAt, new Date(now.getTime() - 48 * 3600_000)),
or(isNull(financialSignals.expiresAt), gt(financialSignals.expiresAt, now)),
),
orderBy: [desc(financialSignals.publishedAt)],
limit: 5,
}),
// Opportunities expiring in 7 days
db.query.opportunities.findMany({
where: and(
eq(opportunities.tenantId, tid),
isNotNull(opportunities.expiresAt),
lte(opportunities.expiresAt, sevenDays),
gt(opportunities.expiresAt, now),
),
orderBy: [desc(opportunities.expiresAt)],
limit: 5,
}),
// Unread notifications (last 24h, max 10)
db.query.notifications.findMany({
where: and(
eq(notifications.tenantId, tid),
eq(notifications.recipientUserId, session.userId),
inArray(notifications.status, ['PENDING', 'DELIVERED'] as any[]),
gte(notifications.createdAt, new Date(now.getTime() - 24 * 3600_000)),
),
orderBy: [desc(notifications.createdAt)],
limit: 10,
}),
]);
const score = Math.max(
0,
100
- atRiskGoals.length * 15
- Math.min(overdueTasks.length * 5, 25)
- pendingDecisions.length * 10
- criticalSignals.length * 10,
);
return {
generatedAt: now.toISOString(),
score,
atRiskGoals,
overdueTasks,
todayTasks,
pendingDecisions,
criticalSignals,
expiringOpportunities: expiringOpps,
notifications: recentNotifications,
};
}
}

View file

@ -1,11 +1,5 @@
import { Module } from '@nestjs/common';
import { AiGatewayModule } from '../ai-gateway/ai-gateway.module';
import { BriefingController } from './briefing.controller';
import { BriefingService } from './briefing.service';
@Module({
imports: [AiGatewayModule],
controllers: [BriefingController],
providers: [BriefingService],
})
@Module({ controllers: [BriefingController] })
export class BriefingModule {}

View file

@ -0,0 +1,49 @@
import {
CanActivate, ExecutionContext, Injectable, UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
/**
* ServiceKeyGuard accepts X-Service-Key header for automation tools (n8n, cron scripts).
* Injects tenantId from N8N_DEFAULT_TENANT_ID env var.
* Only activates when a valid key is present; falls through to JWT guard otherwise.
*/
@Injectable()
export class ServiceKeyGuard implements CanActivate {
private readonly serviceKey: string;
private readonly defaultTenantId: string;
constructor(
private readonly config: ConfigService,
private readonly reflector: Reflector,
) {
this.serviceKey = config.get('N8N_SERVICE_KEY', '');
this.defaultTenantId = config.get('N8N_DEFAULT_TENANT_ID', '');
}
canActivate(ctx: ExecutionContext): boolean {
// Skip on @Public() routes
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
ctx.getHandler(),
ctx.getClass(),
]);
if (isPublic) return true;
if (!this.serviceKey) return false; // service key not configured → fall through
const req = ctx.switchToHttp().getRequest();
const provided = req.headers['x-service-key'] ?? '';
if (!provided || provided !== this.serviceKey) return false;
if (!this.defaultTenantId) {
throw new UnauthorizedException('N8N_DEFAULT_TENANT_ID not configured');
}
// Inject synthetic session so @CurrentSession() works
req.session = { tenantId: this.defaultTenantId, userId: 'n8n-service', isService: true };
return true;
}
}

View file

@ -0,0 +1,81 @@
import { Body, Controller, Get, IsString, Post } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { consentRecords } from '../db/schema';
class GrantConsentDto {
@IsString() purpose!: string;
}
class RevokeConsentDto {
@IsString() purpose!: string;
}
@Controller('consents')
export class ConsentsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession) {
return db.query.consentRecords.findMany({
where: and(
eq(consentRecords.tenantId, session.tenantId),
eq(consentRecords.userId, session.userId),
),
orderBy: [desc(consentRecords.grantedAt)],
});
}
@Post('grant')
async grant(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: GrantConsentDto,
) {
// Revoke any previous record for this purpose first
const existing = await db.query.consentRecords.findFirst({
where: and(
eq(consentRecords.tenantId, session.tenantId),
eq(consentRecords.userId, session.userId),
eq(consentRecords.purpose, dto.purpose),
isNull(consentRecords.revokedAt),
),
});
if (existing) {
return existing;
}
const [created] = await db
.insert(consentRecords)
.values({
tenantId: session.tenantId,
userId: session.userId,
purpose: dto.purpose,
grantedAt: new Date(),
})
.returning();
return created;
}
@Post('revoke')
async revoke(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: RevokeConsentDto,
) {
const active = await db.query.consentRecords.findFirst({
where: and(
eq(consentRecords.tenantId, session.tenantId),
eq(consentRecords.userId, session.userId),
eq(consentRecords.purpose, dto.purpose),
isNull(consentRecords.revokedAt),
),
});
if (!active) {
return { revoked: false, reason: 'no active consent for this purpose' };
}
const [updated] = await db
.update(consentRecords)
.set({ revokedAt: new Date() })
.where(eq(consentRecords.id, active.id))
.returning();
return { revoked: true, record: updated };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ConsentsController } from './consents.controller';
@Module({ controllers: [ConsentsController] })
export class ConsentsModule {}

View file

@ -0,0 +1,78 @@
import { Body, Controller, Delete, Get, HttpCode, IsArray, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { contacts } from '../db/schema';
class CreateContactDto {
@IsString() fullName!: string;
@IsOptional() @IsString() email?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() role?: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsString() linkedinUrl?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() consentStatus?: string;
}
class UpdateContactDto {
@IsOptional() @IsString() fullName?: string;
@IsOptional() @IsString() email?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() role?: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsString() linkedinUrl?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
@IsOptional() @IsString() consentStatus?: string;
}
@Controller('contacts')
export class ContactsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('limit') limitStr?: string) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '200', 10) || 200), 500);
return db.query.contacts.findMany({
where: and(eq(contacts.tenantId, session.tenantId), isNull(contacts.deletedAt)),
orderBy: desc(contacts.createdAt),
limit,
});
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateContactDto) {
const [contact] = await db.insert(contacts).values({
...dto,
tenantId: session.tenantId,
tags: dto.tags ?? [],
}).returning();
return contact;
}
@Get(':id')
async getOne(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
const contact = await db.query.contacts.findFirst({
where: and(eq(contacts.id, id), eq(contacts.tenantId, session.tenantId), isNull(contacts.deletedAt)),
});
if (!contact) throw new NotFoundException('Contact not found');
return contact;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContactDto) {
const [updated] = await db.update(contacts).set({ ...dto, updatedAt: new Date() })
.where(and(eq(contacts.id, id), eq(contacts.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Contact not found');
return updated;
}
@Delete(':id')
@HttpCode(204)
async remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.update(contacts).set({ deletedAt: new Date() })
.where(and(eq(contacts.id, id), eq(contacts.tenantId, session.tenantId)));
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ContactsController } from './contacts.controller';
@Module({ controllers: [ContactsController] })
export class ContactsModule {}

View file

@ -0,0 +1,70 @@
import { Body, Controller, Get, HttpCode, IsArray, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { contracts } from '../db/schema';
class CreateContractDto {
@IsString() title!: string;
@IsOptional() @IsString() contractType?: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsArray() parties?: string[];
@IsOptional() @IsString() startDate?: string;
@IsOptional() @IsString() endDate?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
}
class UpdateContractDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() contractType?: string;
@IsOptional() @IsArray() parties?: string[];
@IsOptional() @IsString() startDate?: string;
@IsOptional() @IsString() endDate?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
}
@Controller('contracts')
export class ContractsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
const where = status
? and(eq(contracts.tenantId, session.tenantId), isNull(contracts.deletedAt), eq(contracts.status, status))
: and(eq(contracts.tenantId, session.tenantId), isNull(contracts.deletedAt));
return db.query.contracts.findMany({ where, orderBy: desc(contracts.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateContractDto) {
const [contract] = await db.insert(contracts).values({
...dto, tenantId: session.tenantId, parties: dto.parties ?? [], tags: dto.tags ?? [],
}).returning();
return contract;
}
@Get(':id')
async getOne(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
const contract = await db.query.contracts.findFirst({
where: and(eq(contracts.id, id), eq(contracts.tenantId, session.tenantId), isNull(contracts.deletedAt)),
});
if (!contract) throw new NotFoundException('Contract not found');
return contract;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDto) {
const [updated] = await db.update(contracts).set({ ...dto, updatedAt: new Date() })
.where(and(eq(contracts.id, id), eq(contracts.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Contract not found');
return updated;
}
@HttpCode(204) @Post(':id/archive')
async archive(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.update(contracts).set({ deletedAt: new Date() })
.where(and(eq(contracts.id, id), eq(contracts.tenantId, session.tenantId)));
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ContractsController } from './contracts.controller';
@Module({ controllers: [ContractsController] })
export class ContractsModule {}

View file

@ -0,0 +1,71 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsIn, IsInt, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { dataSources } from '../db/schema';
class CreateDataSourceDto {
@IsString() name!: string;
@IsOptional() @IsIn(['api','file','manual','database','integration','clickhouse','sheet']) sourceType?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() connectionInfo?: string;
@IsOptional() @IsInt() @Type(() => Number) recordCount?: number;
}
class UpdateDataSourceDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsIn(['active','paused','error','archived']) status?: string;
@IsOptional() @IsInt() @Type(() => Number) recordCount?: number;
@IsOptional() @IsString() connectionInfo?: string;
}
@Controller('data-sources')
export class DataSourcesController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('status') status?: string,
) {
const tid = session.tenantId;
let where = eq(dataSources.tenantId, tid) as ReturnType<typeof eq>;
if (status) where = and(where, eq(dataSources.status, status)) as typeof where;
return db.query.dataSources.findMany({ where, orderBy: [desc(dataSources.createdAt)], limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateDataSourceDto) {
const [row] = await db.insert(dataSources).values({
tenantId: session.tenantId,
name: dto.name,
sourceType: dto.sourceType ?? 'manual',
description: dto.description,
connectionInfo: dto.connectionInfo,
recordCount: dto.recordCount ?? null,
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDataSourceDto,
) {
const upd: Record<string, unknown> = { updatedAt: new Date() };
if (dto.name !== undefined) upd.name = dto.name;
if (dto.description !== undefined) upd.description = dto.description;
if (dto.status !== undefined) upd.status = dto.status;
if (dto.recordCount !== undefined) upd.recordCount = dto.recordCount;
if (dto.connectionInfo !== undefined) upd.connectionInfo = dto.connectionInfo;
if (dto.status === 'active') upd.lastSyncAt = new Date();
const [row] = await db.update(dataSources)
.set(upd)
.where(and(eq(dataSources.id, id), eq(dataSources.tenantId, session.tenantId)))
.returning();
return row;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { DataSourcesController } from './data-sources.controller';
@Module({ controllers: [DataSourcesController] })
export class DataSourcesModule {}

View file

@ -622,3 +622,221 @@ export const researchBriefs = pgTable('research_briefs', {
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// ── Financial Intelligence (CC-052) ─────────────────────────────────────────
// Semnale macro/FX/rates/news ingerate din n8n (World Bank, IMF, Eurostat).
export const financialSignals = pgTable(
'financial_signals',
{
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id'),
/** macro | fx | rates | news | commodity */
category: text('category').notNull().default('macro'),
/** global | eu | ro | de | us */
region: text('region').notNull().default('global'),
/** null = se aplica tuturor nișelor */
niche: text('niche'),
source: text('source').notNull(),
indicatorCode: text('indicator_code'),
title: text('title').notNull(),
/** Rezumat generat de AI per nișă */
summary: text('summary').notNull(),
rawValue: numeric('raw_value', { precision: 18, scale: 4 }),
changePercent: numeric('change_percent', { precision: 10, scale: 4 }),
unit: text('unit'),
/** info | warning | critical */
severity: text('severity').notNull().default('info'),
publishedAt: timestamp('published_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => [
index('financial_signals_published_idx').on(t.category, t.publishedAt),
index('financial_signals_tenant_idx').on(t.tenantId, t.publishedAt),
],
);
// ============================================================
// Contacts (CRM)
// ============================================================
export const contacts = pgTable('contacts', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
organizationId: uuid('organization_id'),
fullName: text('full_name').notNull(),
email: text('email'),
phone: text('phone'),
role: text('role'),
linkedinUrl: text('linkedin_url'),
notes: text('notes'),
tags: text('tags').array().notNull().default([]),
source: text('source'),
consentStatus: text('consent_status').notNull().default('unknown'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
// ============================================================
// Risks (Risk Register)
// ============================================================
export const risks = pgTable('risks', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
title: text('title').notNull(),
description: text('description'),
category: text('category'),
probability: text('probability').notNull().default('medium'),
impact: text('impact').notNull().default('medium'),
status: text('status').notNull().default('open'),
mitigation: text('mitigation'),
owner: text('owner'),
decisionId: uuid('decision_id'),
reviewDueAt: timestamp('review_due_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// ============================================================
// Projects
// ============================================================
export const projects = pgTable('projects', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
organizationId: uuid('organization_id'),
name: text('name').notNull(),
description: text('description'),
status: text('status').notNull().default('planning'),
priority: text('priority').notNull().default('medium'),
startDate: text('start_date'),
dueDate: text('due_date'),
budgetMinorUnits: integer('budget_minor_units'),
currency: text('currency').default('RON'),
tags: text('tags').array().notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
// ============================================================
// Contracts
// ============================================================
export const contracts = pgTable('contracts', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
organizationId: uuid('organization_id'),
title: text('title').notNull(),
contractType: text('contract_type').notNull().default('other'),
status: text('status').notNull().default('draft'),
parties: text('parties').array().notNull().default([]),
startDate: text('start_date'),
endDate: text('end_date'),
valueMinorUnits: integer('value_minor_units'),
currency: text('currency').default('RON'),
notes: text('notes'),
tags: text('tags').array().notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
// ============================================================
// Obligations (Deadlines & Obligations)
// ============================================================
export const obligations = pgTable('obligations', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
title: text('title').notNull(),
description: text('description'),
category: text('category').notNull().default('contractual'),
status: text('status').notNull().default('pending'),
dueDate: text('due_date'),
owner: text('owner'),
contractId: uuid('contract_id'),
evidenceUrl: text('evidence_url'),
riskLevel: text('risk_level').notNull().default('medium'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// ============================================================
// Pipeline Deals (Sales Pipeline)
// ============================================================
export const pipelineDeals = pgTable('pipeline_deals', {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
organizationId: uuid('organization_id'),
contactId: uuid('contact_id'),
title: text('title').notNull(),
stage: text('stage').notNull().default('identified'),
valueMinorUnits: integer('value_minor_units'),
currency: text('currency').default('RON'),
probability: integer('probability').default(50),
expectedCloseDate: text('expected_close_date'),
notes: text('notes'),
tags: text('tags').array().notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
// ——— CC-066 ———
export const actionPlans = pgTable('action_plans', {
id: uuid('id').defaultRandom().primaryKey().notNull(),
tenantId: uuid('tenant_id').notNull(),
workspaceId: uuid('workspace_id'),
title: text('title').notNull(),
description: text('description'),
status: text('status').default('draft').notNull(),
priority: text('priority').default('medium').notNull(),
owner: text('owner'),
dueDate: date('due_date'),
completedAt: timestamp('completed_at'),
decisionId: uuid('decision_id'),
goalId: uuid('goal_id'),
projectId: uuid('project_id'),
tags: text('tags').array().default([]).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
});
export const scenarios = pgTable('scenarios', {
id: uuid('id').defaultRandom().primaryKey().notNull(),
tenantId: uuid('tenant_id').notNull(),
decisionId: uuid('decision_id'),
title: text('title').notNull(),
description: text('description'),
probability: text('probability').default('medium').notNull(),
outcome: text('outcome'),
financialImpactMinorUnits: numeric('financial_impact_minor_units', { precision: 15, scale: 0 }),
financialImpactCurrency: text('financial_impact_currency').default('RON').notNull(),
impactDirection: text('impact_direction').default('neutral').notNull(),
status: text('status').default('hypothetical').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// ——— CC-068 ———
export const dataSources = pgTable('data_sources', {
id: uuid('id').defaultRandom().primaryKey().notNull(),
tenantId: uuid('tenant_id').notNull(),
name: text('name').notNull(),
sourceType: text('source_type').default('manual').notNull(),
description: text('description'),
connectionInfo: text('connection_info'),
status: text('status').default('active').notNull(),
lastSyncAt: timestamp('last_sync_at'),
recordCount: integer('record_count'),
tags: text('tags').array().default([]).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

View file

@ -1,34 +1,112 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { IsArray, IsOptional, IsString } from 'class-validator';
import { and, desc, eq, isNull, isNotNull, lte } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { CreateDecisionDto, UpdateDecisionDto } from './dto';
import { DecisionsService } from './decisions.service';
import { db } from '../db/client';
import { decisions } from '../db/schema';
class CreateDecisionDto {
@IsString() context!: string;
@IsOptional() @IsArray() options?: string[];
@IsOptional() @IsArray() assumptions?: string[];
@IsOptional() @IsString() workspaceId?: string;
@IsOptional() @IsString() reviewDueAt?: string;
}
class UpdateDecisionDto {
@IsOptional() @IsString() context?: string;
@IsOptional() @IsArray() options?: string[];
@IsOptional() @IsArray() assumptions?: string[];
@IsOptional() @IsString() selectedOption?: string | null;
@IsOptional() @IsString() outcomeReview?: string;
@IsOptional() @IsArray() evidence?: unknown[];
@IsOptional() @IsString() reviewDueAt?: string | null;
}
@Controller('decisions')
export class DecisionsController {
constructor(private readonly decisionsService: DecisionsService) {}
@Get()
list(@CurrentSession() session: AuthenticatedSession) {
return this.decisionsService.list(session);
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('limit') limitStr?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '50', 10) || 50), 200);
return db.query.decisions.findMany({
where: eq(decisions.tenantId, session.tenantId),
orderBy: [desc(decisions.createdAt)],
limit,
});
}
@Get('pending-review')
async pendingReview(@CurrentSession() session: AuthenticatedSession) {
const now = new Date();
return db.query.decisions.findMany({
where: and(
eq(decisions.tenantId, session.tenantId),
isNotNull(decisions.reviewDueAt),
lte(decisions.reviewDueAt, now),
isNull(decisions.outcomeReview),
),
orderBy: [desc(decisions.reviewDueAt)],
limit: 20,
});
}
@Get(':id')
getById(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.decisionsService.getById(session, id);
async getOne(
@CurrentSession() session: AuthenticatedSession,
@Param('id') id: string,
) {
return db.query.decisions.findFirst({
where: and(eq(decisions.id, id), eq(decisions.tenantId, session.tenantId)),
});
}
@Post()
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateDecisionDto) {
return this.decisionsService.create(session, dto);
async create(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: CreateDecisionDto,
) {
const [created] = await db
.insert(decisions)
.values({
tenantId: session.tenantId,
workspaceId: dto.workspaceId ?? null,
ownerUserId: session.userId,
context: dto.context,
options: dto.options ?? [],
assumptions: dto.assumptions ?? [],
evidence: [],
reviewDueAt: dto.reviewDueAt ? new Date(dto.reviewDueAt) : null,
})
.returning();
return created;
}
@Patch(':id')
update(
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Param('id') id: string,
@Body() dto: UpdateDecisionDto,
) {
return this.decisionsService.update(session, id, dto);
const [updated] = await db
.update(decisions)
.set({
...(dto.context !== undefined ? { context: dto.context } : {}),
...(dto.options !== undefined ? { options: dto.options } : {}),
...(dto.assumptions !== undefined ? { assumptions: dto.assumptions } : {}),
...(dto.selectedOption !== undefined ? { selectedOption: dto.selectedOption } : {}),
...(dto.outcomeReview !== undefined ? { outcomeReview: dto.outcomeReview } : {}),
...(dto.evidence !== undefined ? { evidence: dto.evidence } : {}),
...(dto.reviewDueAt !== undefined
? { reviewDueAt: dto.reviewDueAt ? new Date(dto.reviewDueAt) : null }
: {}),
updatedAt: new Date(),
})
.where(and(eq(decisions.id, id), eq(decisions.tenantId, session.tenantId)))
.returning();
return updated;
}
}

View file

@ -1,11 +1,5 @@
import { Module } from '@nestjs/common';
import { DecisionsController } from './decisions.controller';
import { DecisionsService } from './decisions.service';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [AuditModule],
controllers: [DecisionsController],
providers: [DecisionsService],
})
@Module({ controllers: [DecisionsController] })
export class DecisionsModule {}

View file

@ -0,0 +1,31 @@
import { Controller, Get, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { documents } from '../db/schema';
@Controller('documents')
export class DocumentsController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('classification') classification?: string,
@Query('ocrStatus') ocrStatus?: string,
@Query('limit') limitStr?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '200', 10) || 200), 500);
const conditions = [
eq(documents.tenantId, session.tenantId),
isNull(documents.deletedAt),
];
if (classification) conditions.push(eq(documents.classification, classification as 'c0' | 'c1' | 'c2' | 'c3' | 'c4'));
if (ocrStatus) conditions.push(eq(documents.ocrStatus, ocrStatus));
return db.query.documents.findMany({
where: and(...conditions),
orderBy: desc(documents.createdAt),
limit,
});
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { DocumentsController } from './documents.controller';
@Module({ controllers: [DocumentsController] })
export class DocumentsModule {}

View file

@ -0,0 +1,130 @@
import { Body, Controller, Get, IsString, Post } from '@nestjs/common';
import { eq, isNull, and } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import {
organizations,
tasks,
goals,
decisions,
transactions,
aiRequests,
consentRecords,
notifications,
researchBriefs,
savedSegments,
opportunities,
} from '../db/schema';
class DeletionRequestDto {
@IsString() reason!: string;
}
@Controller('export')
export class ExportController {
@Get('data')
async exportData(@CurrentSession() session: AuthenticatedSession) {
const tid = session.tenantId;
const uid = session.userId;
const [
orgs,
tks,
gls,
decs,
txs,
aiReqs,
consents,
notifs,
briefs,
segs,
opps,
] = await Promise.all([
db.query.organizations.findMany({
where: and(eq(organizations.tenantId, tid), isNull(organizations.deletedAt)),
}),
db.query.tasks.findMany({ where: eq(tasks.tenantId, tid) }),
db.query.goals.findMany({ where: eq(goals.tenantId, tid) }),
db.query.decisions.findMany({ where: eq(decisions.tenantId, tid) }),
db.query.transactions.findMany({ where: eq(transactions.tenantId, tid) }),
db.query.aiRequests.findMany({
where: eq(aiRequests.tenantId, tid),
columns: { contextManifest: false },
}),
db.query.consentRecords.findMany({
where: and(
eq(consentRecords.tenantId, tid),
eq(consentRecords.userId, uid),
),
}),
db.query.notifications.findMany({ where: eq(notifications.tenantId, tid) }),
db.query.researchBriefs.findMany({ where: eq(researchBriefs.tenantId, tid) }),
db.query.savedSegments.findMany({ where: eq(savedSegments.tenantId, tid) }),
db.query.opportunities.findMany({ where: eq(opportunities.tenantId, tid) }),
]);
return {
exportedAt: new Date().toISOString(),
tenantId: tid,
userId: uid,
schema: '1.0',
data: {
organizations: orgs,
tasks: tks,
goals: gls,
decisions: decs,
transactions: txs,
aiRequests: aiReqs,
consentRecords: consents,
notifications: notifs,
researchBriefs: briefs,
savedSegments: segs,
opportunities: opps,
},
counts: {
organizations: orgs.length,
tasks: tks.length,
goals: gls.length,
decisions: decs.length,
transactions: txs.length,
aiRequests: aiReqs.length,
consentRecords: consents.length,
notifications: notifs.length,
researchBriefs: briefs.length,
savedSegments: segs.length,
opportunities: opps.length,
},
};
}
@Post('deletion-request')
async deletionRequest(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: DeletionRequestDto,
) {
// Record deletion request as a consent record with special purpose
const existing = await db.query.consentRecords.findFirst({
where: and(
eq(consentRecords.tenantId, session.tenantId),
eq(consentRecords.userId, session.userId),
eq(consentRecords.purpose, 'account_deletion_requested'),
isNull(consentRecords.revokedAt),
),
});
if (existing) {
return { submitted: true, alreadyPending: true, submittedAt: existing.grantedAt };
}
const [record] = await db
.insert(consentRecords)
.values({
tenantId: session.tenantId,
userId: session.userId,
purpose: 'account_deletion_requested',
grantedAt: new Date(),
metadata: { reason: dto.reason },
})
.returning();
return { submitted: true, alreadyPending: false, submittedAt: record.grantedAt };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
@Module({ controllers: [ExportController] })
export class ExportModule {}

View file

@ -0,0 +1,57 @@
import { Body, Controller, Post } from '@nestjs/common';
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
import { ExternalDataService } from './external-data.service';
class LegislationSearchDto {
@IsString() scenario!: string;
@IsOptional() @IsString() country?: string;
@IsOptional() @IsNumber() topK?: number;
}
class LegislationAskDto {
@IsString() message!: string;
@IsOptional() @IsString() country?: string;
@IsOptional() @IsEnum(['general', 'fiscal', 'national']) type?: 'general' | 'fiscal' | 'national';
@IsOptional() @IsString() sessionId?: string;
}
class CapabilityCheckDto {
@IsString() capability!: string;
@IsOptional() @IsString() context?: string;
}
@Controller('external')
export class ExternalDataController {
constructor(private readonly svc: ExternalDataService) {}
/**
* POST /v1/external/legislation/search
* Vector search direct în corpusul legislativ returnează secțiuni text.
* 'scenario': descrie situația completă (ex: "Am o firmă GmbH și vreau să angajez remote")
*/
@Post('legislation/search')
searchLegislation(@Body() dto: LegislationSearchDto) {
return this.svc.searchLegislation(dto.scenario, dto.country, dto.topK);
}
/**
* POST /v1/external/legislation/ask
* Răspuns AI via agent AnythingLLM, rutare automată după country + type.
* country: RO | DE | FR | UK | EU
* type: general | fiscal | national
*/
@Post('legislation/ask')
askLegislation(@Body() dto: LegislationAskDto) {
return this.svc.askLegislationAgent(
dto.message,
dto.country ?? 'RO',
dto.type ?? 'general',
dto.sessionId,
);
}
@Post('capability/check')
checkCapability(@Body() dto: CapabilityCheckDto) {
return this.svc.checkCapability(dto.capability, dto.context);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExternalDataController } from './external-data.controller';
import { ExternalDataService } from './external-data.service';
@Module({
controllers: [ExternalDataController],
providers: [ExternalDataService],
exports: [ExternalDataService],
})
export class ExternalDataModule {}

View file

@ -0,0 +1,106 @@
import { BadGatewayException, Injectable } from '@nestjs/common';
const TIMEOUT_MS = 15_000;
async function jsonFetch<T>(url: string, opts: RequestInit = {}): Promise<T> {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...opts.headers },
signal: ctrl.signal,
});
clearTimeout(id);
if (!res.ok) throw new BadGatewayException(`upstream returned ${res.status}`);
return (await res.json()) as T;
} catch (err) {
clearTimeout(id);
if (err instanceof BadGatewayException) throw err;
throw new BadGatewayException('upstream service unreachable');
}
}
/**
* Mapare țară workspace AnythingLLM.
* Fiecare workspace conține corpusul legal/fiscal al jurisdicției respective.
*/
const COUNTRY_WORKSPACE: Record<string, string> = {
RO: 'agent-juridic-romania',
DE: 'national-germania',
'DE-fiscal': 'fiscal-germania',
'RO-fiscal': 'fiscal-romania',
FR: 'national-franta',
'FR-fiscal': 'fiscal-franta',
UK: 'national-uk',
'UK-fiscal': 'fiscal-uk',
EU: 'agent-juridic-romania', // fallback general
};
@Injectable()
export class ExternalDataService {
/** ceo-os-legislation-api: RAG vector search direct în corpus legislativ */
private readonly legislationUrl =
process.env.LEGISLATION_API_URL ?? 'http://91.98.39.120:9404';
private readonly anythingLlmUrl =
process.env.ANYTHINGLLM_URL ??
'http://anythingllm-n3n3xi75sj0xkh3oey17n962.91.98.39.120.sslip.io';
private readonly anythingLlmKey =
process.env.ANYTHINGLLM_API_KEY ?? '';
/**
* Vector search în corpusul legislativ returnează secțiuni text brut.
* 'scenario' trebuie fie o descriere completă a situației (nu keyword).
*/
async searchLegislation(scenario: string, country?: string, topK = 8) {
return jsonFetch(`${this.legislationUrl}/search_legislation`, {
method: 'POST',
body: JSON.stringify({ scenario, country: country ?? null, top_k: topK }),
});
}
/**
* Răspuns AI interpretat via agent AnythingLLM.
* Rutare automată pe workspace-ul potrivit după țară și tip.
*/
async askLegislationAgent(
message: string,
country = 'RO',
type: 'general' | 'fiscal' | 'national' = 'general',
sessionId?: string,
) {
if (!this.anythingLlmKey) {
throw new BadGatewayException('ANYTHINGLLM_API_KEY not configured');
}
const key = type === 'fiscal' ? `${country}-fiscal` : country;
const workspace =
COUNTRY_WORKSPACE[key] ??
COUNTRY_WORKSPACE[country] ??
'agent-juridic-romania';
return jsonFetch(
`${this.anythingLlmUrl}/api/v1/workspace/${workspace}/chat`,
{
method: 'POST',
headers: { Authorization: `Bearer ${this.anythingLlmKey}` },
body: JSON.stringify({
message,
mode: 'chat',
sessionId: sessionId ?? `ceo-os-${country}-${Date.now()}`,
attachments: [],
}),
},
);
}
async checkCapability(capability: string, context?: string) {
const capUrl = process.env.CAPABILITY_API_URL ?? 'http://91.98.39.120:9402';
return jsonFetch(`${capUrl}/check_capability`, {
method: 'POST',
body: JSON.stringify({ capability, context }),
});
}
}

View file

@ -0,0 +1,24 @@
import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FinancialSignalItemDto {
@IsString() source!: string;
@IsString() title!: string;
@IsString() summary!: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() region?: string;
@IsOptional() @IsString() niche?: string;
@IsOptional() @IsString() indicatorCode?: string;
@IsOptional() @IsNumber() rawValue?: number;
@IsOptional() @IsNumber() changePercent?: number;
@IsOptional() @IsString() unit?: string;
@IsOptional() @IsString() severity?: string;
@IsOptional() @IsString() publishedAt?: string;
@IsOptional() @IsString() expiresAt?: string;
}
export class IngestSignalsDto {
@IsOptional() @IsString() tenantId?: string;
@IsArray() @ValidateNested({ each: true }) @Type(() => FinancialSignalItemDto)
signals!: FinancialSignalItemDto[];
}

View file

@ -0,0 +1,70 @@
import { Body, Controller, Get, Headers, Post, Query, UnauthorizedException } from '@nestjs/common';
import { desc, isNull, or, eq, and, gte } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { Public } from '../auth/public.decorator';
import { db } from '../db/client';
import { financialSignals } from '../db/schema';
import { IngestSignalsDto } from './dto';
@Controller('financial-intelligence')
export class FinancialIntelligenceController {
@Get('signals')
async signals(
@CurrentSession() session: AuthenticatedSession,
@Query('limit') limitStr?: string,
@Query('category') category?: string,
@Query('region') region?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '50', 10) || 50), 200);
const now = new Date();
const conditions = [
or(isNull(financialSignals.tenantId), eq(financialSignals.tenantId, session.tenantId)),
or(isNull(financialSignals.expiresAt), gte(financialSignals.expiresAt, now)),
];
if (category) conditions.push(eq(financialSignals.category, category));
if (region) conditions.push(eq(financialSignals.region, region));
return db.query.financialSignals.findMany({
where: and(...conditions),
orderBy: [desc(financialSignals.publishedAt)],
limit,
});
}
/** POST /v1/financial-intelligence/ingest — n8n webhook, semnat cu X-Ingest-Key */
@Public()
@Post('ingest')
async ingest(
@Headers('x-ingest-key') ingestKey: string | undefined,
@Body() dto: IngestSignalsDto,
) {
const expected = process.env.INGEST_SECRET;
if (expected && ingestKey !== expected) {
throw new UnauthorizedException('Invalid ingest key');
}
if (!dto.signals || dto.signals.length === 0) return { ingested: 0 };
const rows = dto.signals.map((s) => ({
tenantId: dto.tenantId ?? null,
category: s.category ?? 'macro',
region: s.region ?? 'global',
niche: s.niche ?? null,
source: s.source,
indicatorCode: s.indicatorCode ?? null,
title: s.title,
summary: s.summary,
rawValue: s.rawValue != null ? String(s.rawValue) : null,
changePercent: s.changePercent != null ? String(s.changePercent) : null,
unit: s.unit ?? null,
severity: s.severity ?? 'info',
publishedAt: s.publishedAt ? new Date(s.publishedAt) : new Date(),
expiresAt: s.expiresAt ? new Date(s.expiresAt) : null,
}));
await db.insert(financialSignals).values(rows);
return { ingested: rows.length };
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { FinancialIntelligenceController } from './financial-intelligence.controller';
@Module({
controllers: [FinancialIntelligenceController],
})
export class FinancialIntelligenceModule {}

View file

@ -1,42 +1,84 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { IsOptional, IsString } from 'class-validator';
import { and, desc, eq, isNull, or, inArray } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { CreateGoalDto, GOAL_STATUSES, UpdateGoalDto, type GoalStatusValue } from './dto';
import { GoalsService } from './goals.service';
import { db } from '../db/client';
import { goals } from '../db/schema';
class CreateGoalDto {
@IsString() horizon!: string;
@IsString() metric!: string;
@IsString() target!: string;
@IsOptional() @IsString() workspaceId?: string;
}
class UpdateGoalDto {
@IsOptional() @IsString() status?: string;
@IsOptional() milestones?: unknown[];
@IsOptional() @IsString() target?: string;
}
@Controller('goals')
export class GoalsController {
constructor(private readonly goalsService: GoalsService) {}
@Get()
list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
if (status && !GOAL_STATUSES.includes(status as GoalStatusValue)) {
throw new BadRequestException(`status must be one of: ${GOAL_STATUSES.join(', ')}`);
}
return this.goalsService.list(session, status as GoalStatusValue | undefined);
async list(@CurrentSession() session: AuthenticatedSession) {
return db.query.goals.findMany({
where: and(eq(goals.tenantId, session.tenantId), isNull(goals.deletedAt)),
orderBy: [desc(goals.createdAt)],
limit: 100,
});
}
@Get(':id')
getById(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.goalsService.getById(session, id);
@Get('at-risk')
async atRisk(@CurrentSession() session: AuthenticatedSession) {
return db.query.goals.findMany({
where: and(
eq(goals.tenantId, session.tenantId),
isNull(goals.deletedAt),
inArray(goals.status, ['at_risk', 'not_started']),
),
orderBy: [desc(goals.createdAt)],
limit: 20,
});
}
@Post()
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateGoalDto) {
return this.goalsService.create(session, dto);
async create(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: CreateGoalDto,
) {
const [created] = await db
.insert(goals)
.values({
tenantId: session.tenantId,
workspaceId: dto.workspaceId ?? null,
ownerUserId: session.userId,
horizon: dto.horizon,
metric: dto.metric,
target: dto.target,
})
.returning();
return created;
}
@Patch(':id')
update(
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Param('id') id: string,
@Body() dto: UpdateGoalDto,
) {
return this.goalsService.update(session, id, dto);
}
@Delete(':id')
softDelete(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.goalsService.softDelete(session, id);
const [updated] = await db
.update(goals)
.set({
...(dto.status !== undefined ? { status: dto.status as any } : {}),
...(dto.milestones !== undefined ? { milestones: dto.milestones } : {}),
...(dto.target !== undefined ? { target: dto.target } : {}),
updatedAt: new Date(),
version: db.$count(goals, eq(goals.id, id)),
})
.where(and(eq(goals.id, id), eq(goals.tenantId, session.tenantId)))
.returning();
return updated;
}
}

View file

@ -1,12 +1,5 @@
import { Module } from '@nestjs/common';
import { GoalsController } from './goals.controller';
import { GoalsService } from './goals.service';
import { EventsModule } from '../events/events.module';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [EventsModule, AuditModule],
controllers: [GoalsController],
providers: [GoalsService],
})
@Module({ controllers: [GoalsController] })
export class GoalsModule {}

View file

@ -4,7 +4,9 @@ import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { AppModule } from './app.module';
import { db } from './db/client';
const REQUIRED_ENV_VARS = [
'DATABASE_URL',
@ -26,6 +28,18 @@ function validateEnv(): void {
async function bootstrap() {
validateEnv();
// Apply pending DB migrations — skip if schema was pre-initialized via drizzle push
try {
await migrate(db, { migrationsFolder: './drizzle' });
} catch (err) {
const msg = (err as Error & { message: string }).message ?? '';
if (msg.includes('already exists')) {
console.warn('[bootstrap] Schema already initialized — migration skipped. Run db:migrate manually if needed.');
} else {
throw err;
}
}
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
app.use(helmet());

View file

@ -0,0 +1,64 @@
import { Body, Controller, Get, Param, Post, ParseUUIDPipe, UseGuards } from '@nestjs/common';
import { AiService, HermesMessage } from './ai.service';
import { AskDto } from './dto/ask.dto';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentSession } from '../../common/decorators/current-session.decorator';
@Controller('ai')
export class AiController {
constructor(private readonly ai: AiService) {}
/** POST /v1/ai/ask — general question with CEO OS context */
@Post('ask')
async ask(
@Body() dto: AskDto,
@CurrentSession() session: { tenantId: string },
) {
const ctx = dto.context ? JSON.parse(dto.context) : {};
ctx.tenantId = session.tenantId;
ctx.timestamp = new Date().toISOString();
const systemPrompt = this.ai.buildSystemPrompt(ctx);
const messages: HermesMessage[] = [
{ role: 'system', content: systemPrompt },
...dto.messages,
];
const reply = await this.ai.chat(messages);
return { reply, model: process.env.HERMES_MODEL ?? 'hermes' };
}
/** GET /v1/ai/daily-brief — AI synthesizes today's priorities */
@Get('daily-brief')
async dailyBrief(@CurrentSession() session: { tenantId: string }) {
const messages: HermesMessage[] = [
{
role: 'system',
content: [
'Ești asistentul CEO OS. Generezi un brief zilnic scurt și acționabil.',
'Folosește bullet points. Max 200 cuvinte.',
`TenantId: ${session.tenantId}`,
`Data: ${new Date().toLocaleDateString('ro-RO', { weekday: 'long', day: 'numeric', month: 'long' })}`,
].join('\n'),
},
{
role: 'user',
content: 'Generează brieful de dimineață: ce ar trebui să prioritizez azi? Structurat în: Focus principal, Top 3 task-uri, Atenție la.',
},
];
const brief = await this.ai.chat(messages, 512);
return { brief, generatedAt: new Date().toISOString() };
}
/** POST /v1/ai/status — health check Hermes */
@Get('status')
async status() {
try {
const reply = await this.ai.chat([{ role: 'user', content: 'ping' }], 10);
return { status: 'ok', hermes: process.env.HERMES_BASE_URL, reply };
} catch (err: any) {
return { status: 'error', message: err.message };
}
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
@Module({
providers: [AiService],
controllers: [AiController],
exports: [AiService],
})
export class AiModule {}

View file

@ -0,0 +1,57 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface HermesMessage { role: 'system' | 'user' | 'assistant'; content: string; }
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly model: string;
constructor(private readonly config: ConfigService) {
this.baseUrl = config.get<string>('HERMES_BASE_URL', 'http://152.53.112.35:8080');
this.apiKey = config.get<string>('HERMES_API_KEY', '');
this.model = config.get<string>('HERMES_MODEL', 'gemini-2.0-flash');
}
async chat(messages: HermesMessage[], maxTokens = 2048): Promise<string> {
const url = `${this.baseUrl}/v1/chat/completions`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.apiKey) headers['Authorization'] = `Bearer ${this.apiKey}`;
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ model: this.model, messages, max_tokens: maxTokens }),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
this.logger.error(`Hermes unreachable: ${err}`);
throw new Error('Hermes is unreachable. Check HERMES_BASE_URL.');
}
if (!res.ok) {
const body = await res.text();
this.logger.error(`Hermes ${res.status}: ${body}`);
throw new Error(`Hermes error ${res.status}`);
}
const data = await res.json() as { choices: { message: { content: string } }[] };
return data.choices?.[0]?.message?.content ?? '';
}
buildSystemPrompt(context: Record<string, unknown>): string {
return [
'Ești asistentul AI al platformei CEO OS (boardmind.dev).',
'Răspunzi în limba în care ți se pune întrebarea (RO/EN/DE).',
'Ești concis, practic și bazat pe date.',
'',
'## Contextul curent al utilizatorului',
JSON.stringify(context, null, 2),
].join('\n');
}
}

View file

@ -0,0 +1,21 @@
import { IsString, IsOptional, IsArray, ValidateNested, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class ChatMessageDto {
@IsIn(['user', 'assistant', 'system'])
role: 'user' | 'assistant' | 'system';
@IsString()
content: string;
}
export class AskDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages: ChatMessageDto[];
@IsOptional()
@IsString()
context?: string; // JSON string of CEO OS context snapshot
}

View file

@ -0,0 +1,32 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { IntelligenceService } from './intelligence.service';
@Controller('intelligence')
export class IntelligenceController {
constructor(private readonly svc: IntelligenceService) {}
/** GET /v1/intelligence/status */
@Get('status')
status() { return this.svc.status(); }
/** GET /v1/intelligence/tables */
@Get('tables')
tables() { return this.svc.safeQuery('tables'); }
/** GET /v1/intelligence/databases */
@Get('databases')
databases() { return this.svc.safeQuery('databases'); }
/** POST /v1/intelligence/query — named safe queries only */
@Post('query')
query(@Body() body: { queryKey: string; params?: Record<string, string> }) {
return this.svc.safeQuery(body.queryKey, body.params ?? {});
}
/** GET /v1/intelligence/legislation?q=... */
@Get('legislation')
legislation(@Query('q') q: string) {
if (!q) return { data: [], rows: 0, elapsed: 0 };
return this.svc.safeQuery('legislation_search', { q });
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { IntelligenceService } from './intelligence.service';
import { IntelligenceController } from './intelligence.controller';
@Module({
providers: [IntelligenceService],
controllers: [IntelligenceController],
exports: [IntelligenceService],
})
export class IntelligenceModule {}

View file

@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface ChQueryResult {
data: Record<string, unknown>[];
rows: number;
elapsed: number;
error?: string;
}
const SAFE_QUERIES: Record<string, (params: Record<string, string>) => string> = {
money_raise_all: () =>
\`SELECT category, subcategory, region, stage, title, content, format, tags, source, language
FROM money_raise.knowledge_base
ORDER BY category, subcategory LIMIT 500\`,
tables: () =>
`SELECT database, name, engine, total_rows, formatReadableSize(total_bytes) AS size
FROM system.tables
WHERE database NOT IN ('system','information_schema','INFORMATION_SCHEMA')
ORDER BY database, name LIMIT 100`,
legislation_search: ({ q }) =>
`SELECT * FROM default.legislation
WHERE lower(content) LIKE lower('%${q.replace(/'/g, "\\'")}%')
LIMIT 20`,
count_rows: ({ table }) =>
`SELECT count() AS rows FROM ${table.replace(/[^a-zA-Z0-9_.]/g, '')}`,
recent_rows: ({ table, limit = '10' }) =>
`SELECT * FROM ${table.replace(/[^a-zA-Z0-9_.]/g, '')} LIMIT ${Math.min(parseInt(limit) || 10, 100)}`,
databases: () =>
`SELECT name, engine FROM system.databases ORDER BY name`,
};
@Injectable()
export class IntelligenceService {
private readonly logger = new Logger(IntelligenceService.name);
private readonly baseUrl: string;
private readonly user: string;
private readonly password: string;
private readonly database: string;
constructor(private readonly config: ConfigService) {
this.baseUrl = config.get('CLICKHOUSE_URL', 'http://152.53.112.35:8123');
this.user = config.get('CLICKHOUSE_USER', 'default');
this.password = config.get('CLICKHOUSE_PASSWORD', '');
this.database = config.get('CLICKHOUSE_DATABASE', 'default');
}
private authHeader(): string {
return 'Basic ' + Buffer.from(`${this.user}:${this.password}`).toString('base64');
}
async query(sql: string): Promise<ChQueryResult> {
const url = new URL(this.baseUrl);
url.searchParams.set('database', this.database);
url.searchParams.set('default_format', 'JSON');
const t0 = Date.now();
let res: Response;
try {
res = await fetch(url.toString(), {
method: 'POST',
headers: {
'Authorization': this.authHeader(),
'X-ClickHouse-Format': 'JSON',
'Content-Type': 'text/plain',
},
body: sql,
signal: AbortSignal.timeout(15_000),
});
} catch (err: any) {
this.logger.error(`ClickHouse unreachable: ${err.message}`);
return { data: [], rows: 0, elapsed: Date.now() - t0, error: 'ClickHouse unreachable. Check CLICKHOUSE_URL.' };
}
const elapsed = Date.now() - t0;
if (!res.ok) {
const body = await res.text();
return { data: [], rows: 0, elapsed, error: `ClickHouse ${res.status}: ${body.slice(0, 200)}` };
}
const json = await res.json() as { data: Record<string, unknown>[]; rows: number };
return { data: json.data ?? [], rows: json.rows ?? 0, elapsed };
}
async safeQuery(queryKey: string, params: Record<string, string> = {}): Promise<ChQueryResult> {
const builder = SAFE_QUERIES[queryKey];
if (!builder) return { data: [], rows: 0, elapsed: 0, error: `Unknown query: ${queryKey}` };
return this.query(builder(params));
}
async status(): Promise<{ ok: boolean; version?: string; error?: string }> {
const r = await this.query('SELECT version() AS version');
if (r.error) return { ok: false, error: r.error };
return { ok: true, version: String(r.data[0]?.version ?? '') };
}
}

View file

@ -0,0 +1,134 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/**
* TradeConnectorService fetches from external trade APIs and stores in ClickHouse.
* Each source is polled on-demand or via n8n cron.
* Credentials via env vars no hardcoded keys.
*/
@Injectable()
export class TradeConnectorService {
private readonly logger = new Logger(TradeConnectorService.name);
private readonly comtradeKey: string;
constructor(private readonly config: ConfigService) {
this.comtradeKey = config.get('UN_COMTRADE_KEY', '');
}
// ── UN Comtrade ───────────────────────────────────────────────
async fetchComtrade(params: {
reporter: string; // ISO3 or numeric code, e.g. '276' = Germany
partner?: string; // '0' = world
period: string; // 'YYYYMM' or 'YYYY'
hs: string; // HS6 code, e.g. '847150'
flow?: 'M'|'X'; // M=Import X=Export
}): Promise<Record<string, unknown>[]> {
const url = new URL('https://comtradeapi.un.org/data/v1/get/C/M/HS');
url.searchParams.set('reporterCode', params.reporter);
url.searchParams.set('partnerCode', params.partner ?? '0');
url.searchParams.set('period', params.period);
url.searchParams.set('cmdCode', params.hs);
if (params.flow) url.searchParams.set('flowCode', params.flow);
url.searchParams.set('maxRecords', '500');
url.searchParams.set('format', 'JSON');
if (this.comtradeKey) url.searchParams.set('subscription-key', this.comtradeKey);
this.logger.log(`Comtrade fetch: ${url.toString()}`);
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(30_000) });
if (!res.ok) throw new Error(`Comtrade ${res.status}: ${await res.text()}`);
const data = await res.json() as { data?: Record<string, unknown>[] };
return data.data ?? [];
}
// ── IMF World Economic Outlook ────────────────────────────────
async fetchImfWeo(params: {
countries: string[]; // ISO2 codes
indicators: string[]; // e.g. ['NGDP_RPCH','PCPI','BCA_NGDPD']
startYear?: number;
endYear?: number;
}): Promise<Record<string, unknown>[]> {
// IMF API v3: https://www.imf.org/external/datamapper/api/v1/
const rows: Record<string, unknown>[] = [];
for (const ind of params.indicators) {
const url = `https://www.imf.org/external/datamapper/api/v1/${ind}/${params.countries.join('/')}`;
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) { this.logger.warn(`IMF ${res.status} for ${ind}`); continue; }
const data = await res.json() as Record<string, unknown>;
const values = (data.values as Record<string, Record<string, Record<string, number>>>)?.[ind] ?? {};
for (const country of Object.keys(values)) {
for (const [year, value] of Object.entries(values[country])) {
const y = parseInt(year);
if (params.startYear && y < params.startYear) continue;
if (params.endYear && y > params.endYear) continue;
rows.push({ country_code: country, indicator_code: ind, year: y, value });
}
}
}
return rows;
}
// ── World Bank Open Data ──────────────────────────────────────
async fetchWorldBank(params: {
countries: string[]; // ISO2 codes or 'all'
indicators: string[]; // e.g. ['NY.GDP.MKTP.CD','TM.VAL.MRCH.CD.WT']
startYear?: number;
endYear?: number;
}): Promise<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = [];
const sY = params.startYear ?? 2000;
const eY = params.endYear ?? new Date().getFullYear();
for (const ind of params.indicators) {
const ctry = params.countries.join(';');
const url = `https://api.worldbank.org/v2/country/${ctry}/indicator/${ind}?date=${sY}:${eY}&format=json&per_page=1000`;
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) { this.logger.warn(`WorldBank ${res.status} for ${ind}`); continue; }
const data = await res.json() as [unknown, Record<string, unknown>[] | undefined];
for (const row of data[1] ?? []) {
rows.push({
country_code: row.countryiso3code,
country: (row.country as { value?: string })?.value,
indicator_code: ind,
indicator: (row.indicator as { value?: string })?.value,
year: parseInt(String(row.date)),
value: row.value,
});
}
}
return rows;
}
// ── Eurostat Comext ───────────────────────────────────────────
async fetchEurostat(params: {
product: string; // CN8 code, e.g. '84715000'
reporter?: string; // e.g. 'DE'
period?: string; // e.g. '2024' or '202401'
}): Promise<Record<string, unknown>[]> {
// Eurostat SDMX REST API
const period = params.period ?? new Date().getFullYear().toString();
const reporter = params.reporter ?? 'EU27_2020';
const url = `https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/data/DS-045409/${period}.${reporter}.WORLD.${params.product}.1+2/?format=JSON`;
this.logger.log(`Eurostat fetch: ${url}`);
try {
const res = await fetch(url, { signal: AbortSignal.timeout(20_000) });
if (!res.ok) return [];
const data = await res.json() as Record<string, unknown>;
// Parse SDMX-JSON structure
return this.parseEurostatResponse(data, params);
} catch (err: any) {
this.logger.warn(`Eurostat error: ${err.message}`);
return [];
}
}
private parseEurostatResponse(data: Record<string, unknown>, params: Record<string, string>): Record<string, unknown>[] {
// SDMX-JSON parsing — simplified
try {
const obs = (data as any)?.dataSets?.[0]?.observations ?? {};
const rows: Record<string, unknown>[] = [];
for (const [key, values] of Object.entries(obs)) {
rows.push({ ...params, observation_key: key, value: (values as number[])[0] });
}
return rows;
} catch { return []; }
}
}

View file

@ -0,0 +1,100 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { TradeConnectorService } from './trade-connector.service';
import { IntelligenceService } from '../intelligence/intelligence.service';
import { AiService } from '../ai/ai.service';
@Controller('trade')
export class TradeController {
constructor(
private readonly connector: TradeConnectorService,
private readonly intelligence: IntelligenceService,
private readonly ai: AiService,
) {}
/** GET /v1/trade/comtrade?reporter=276&hs=847150&period=2024 */
@Get('comtrade')
async comtrade(
@Query('reporter') reporter: string = '276',
@Query('partner') partner: string = '0',
@Query('period') period: string,
@Query('hs') hs: string,
@Query('flow') flow: 'M'|'X',
) {
if (!hs) return { error: 'hs parameter required (HS6 code)' };
const p = period ?? new Date().getFullYear().toString();
const data = await this.connector.fetchComtrade({ reporter, partner, period: p, hs, flow });
return { source: 'un_comtrade', rows: data.length, data };
}
/** GET /v1/trade/imf?countries=DE,RO,FR&indicators=NGDP_RPCH,PCPI */
@Get('imf')
async imf(
@Query('countries') countries: string = 'DEU,ROM',
@Query('indicators') indicators: string = 'NGDP_RPCH,PCPI,BCA_NGDPD',
@Query('startYear') startYear: string = '2015',
) {
const data = await this.connector.fetchImfWeo({
countries: countries.split(','),
indicators: indicators.split(','),
startYear: parseInt(startYear),
});
return { source: 'imf_weo', rows: data.length, data };
}
/** GET /v1/trade/worldbank?countries=DE,RO&indicators=NY.GDP.MKTP.CD */
@Get('worldbank')
async worldbank(
@Query('countries') countries: string = 'DE;RO',
@Query('indicators') indicators: string = 'NY.GDP.MKTP.CD,TM.VAL.MRCH.CD.WT,TX.VAL.MRCH.CD.WT',
) {
const data = await this.connector.fetchWorldBank({
countries: countries.split(';'),
indicators: indicators.split(','),
});
return { source: 'world_bank', rows: data.length, data };
}
/** GET /v1/trade/eurostat?product=84715000&reporter=DE&period=2024 */
@Get('eurostat')
async eurostat(
@Query('product') product: string,
@Query('reporter') reporter: string = 'DE',
@Query('period') period: string,
) {
if (!product) return { error: 'product (CN8 code) required' };
const data = await this.connector.fetchEurostat({ product, reporter, period });
return { source: 'eurostat_comext', rows: data.length, data };
}
/** POST /v1/trade/predict — AI prediction for trade flow */
@Post('predict')
async predict(
@Body() body: {
commodity: string;
commodityCode?: string;
reporter: string;
partner?: string;
horizonMonths?: number;
},
) {
const horizon = body.horizonMonths ?? 12;
const prompt = [
`Analizează și predicționează fluxul de comerț pentru:`,
`- Marfă: ${body.commodity} (cod: ${body.commodityCode ?? 'N/A'})`,
`- Țara raportoare: ${body.reporter}`,
`- Partener: ${body.partner ?? 'Global'}`,
`- Orizont: ${horizon} luni`,
``,
`Bazat pe datele istorice disponibile și tendințele macroeconomice (IMF/World Bank/Eurostat),`,
`estimează: volum tranzacționat (USD), direcție tendință, factori de risc, oportunități.`,
`Răspunde structurat în: Estimare cantitativă | Tendință | Factori cheie | Risc | Oportunitate.`,
].join('\n');
const reply = await this.ai.chat([
{ role: 'system', content: 'Ești un expert în comerț internațional cu acces la date Comtrade, IMF și World Bank. Furnizezi analize cantitative și calitative.' },
{ role: 'user', content: prompt },
], 1024);
return { prediction: reply, commodity: body.commodity, reporter: body.reporter, horizonMonths: horizon };
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TradeConnectorService } from './trade-connector.service';
import { TradeController } from './trade.controller';
import { IntelligenceModule } from '../intelligence/intelligence.module';
import { AiModule } from '../ai/ai.module';
@Module({
imports: [IntelligenceModule, AiModule],
providers: [TradeConnectorService],
controllers: [TradeController],
exports: [TradeConnectorService],
})
export class TradeModule {}

View file

@ -0,0 +1,68 @@
import {
Body, Controller, Headers, HttpCode, Logger, Post, RawBodyRequest, Req,
} from '@nestjs/common';
import { Request } from 'express';
import { Public } from '../../common/decorators/public.decorator';
import { WebhooksService, PaperlessPayload } from './webhooks.service';
import { ObservationsService } from '../observations/observations.service';
import { WorkspacesService } from '../workspaces/workspaces.service';
@Controller('webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
constructor(
private readonly webhooks: WebhooksService,
private readonly observations: ObservationsService,
private readonly workspaces: WorkspacesService,
) {}
/**
* POST /v1/webhooks/paperless
* Paperless-ngx custom field: X-Paperless-Tenant tenantId
* Optionally validated with PAPERLESS_WEBHOOK_SECRET (HMAC-SHA256).
*/
@Public()
@Post('paperless')
@HttpCode(200)
async paperless(
@Body() payload: PaperlessPayload,
@Headers('x-hub-signature-256') sig: string,
@Headers('x-paperless-tenant') tenantHint: string,
@Req() req: Request,
) {
this.webhooks.logIncoming('paperless', payload);
const rawBody = JSON.stringify(payload);
if (sig && !this.webhooks.validateSignature(rawBody, sig)) {
this.logger.warn('Paperless webhook: invalid signature — rejected');
return { ok: false, reason: 'invalid_signature' };
}
// Resolve tenant: from header, or fall back to first active workspace
let tenantId = tenantHint ?? '';
if (!tenantId) {
const ws = await this.workspaces.findFirst();
tenantId = ws?.id ?? '';
}
if (!tenantId) {
this.logger.warn('Paperless webhook: no tenantId resolved');
return { ok: false, reason: 'no_tenant' };
}
const obs = this.webhooks.buildObservation(payload);
try {
await this.observations.create(tenantId, obs as any);
return { ok: true, tenantId, metric: obs.metric };
} catch (err: any) {
this.logger.error(`Failed to save observation: ${err.message}`);
return { ok: false, reason: err.message };
}
}
/** POST /v1/webhooks/ping — health probe */
@Public()
@Post('ping')
@HttpCode(200)
ping() { return { ok: true, ts: new Date().toISOString() }; }
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WebhooksService } from './webhooks.service';
import { WebhooksController } from './webhooks.controller';
import { ObservationsModule } from '../observations/observations.module';
import { WorkspacesModule } from '../workspaces/workspaces.module';
@Module({
imports: [ObservationsModule, WorkspacesModule],
providers: [WebhooksService],
controllers: [WebhooksController],
})
export class WebhooksModule {}

View file

@ -0,0 +1,65 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
export interface PaperlessPayload {
document_id?: number;
id?: number;
title?: string;
document_type?: string;
correspondent?: string;
tags?: string[];
created?: string;
added?: string;
content?: string;
original_file_name?: string;
}
@Injectable()
export class WebhooksService {
private readonly logger = new Logger(WebhooksService.name);
private readonly secret: string;
constructor(private readonly config: ConfigService) {
this.secret = config.get('PAPERLESS_WEBHOOK_SECRET', '');
}
validateSignature(payload: string, signature: string): boolean {
if (!this.secret) return true; // secret not configured → allow (dev mode)
const expected = 'sha256=' + crypto
.createHmac('sha256', this.secret)
.update(payload)
.digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
} catch {
return false;
}
}
buildObservation(doc: PaperlessPayload) {
const docId = doc.document_id ?? doc.id ?? 0;
const tags = ['document', 'inbox', ...(doc.tags ?? [])];
if (doc.document_type) tags.push(doc.document_type.toLowerCase().replace(/\s+/g, '-'));
return {
metric: 'document-received',
value: doc.title ?? `Document #${docId}`,
unit: doc.document_type ?? 'document',
subjectType: 'document',
confidence: 1,
source: doc.correspondent ?? undefined,
observedAt: doc.added ?? doc.created ?? new Date().toISOString(),
metadata: {
paperlessId: docId,
originalFile: doc.original_file_name,
tags: doc.tags,
excerpt: doc.content?.slice(0, 500),
},
};
}
logIncoming(type: string, payload: unknown) {
this.logger.log(`Webhook received: ${type}${JSON.stringify(payload).slice(0, 200)}`);
}
}

View file

@ -0,0 +1,51 @@
import { Body, Controller, Get, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { obligations } from '../db/schema';
class CreateObligationDto {
@IsString() title!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() contractId?: string;
@IsOptional() @IsString() riskLevel?: string;
}
class UpdateObligationDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() evidenceUrl?: string;
@IsOptional() @IsString() riskLevel?: string;
}
@Controller('obligations')
export class ObligationsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
const where = status
? and(eq(obligations.tenantId, session.tenantId), eq(obligations.status, status))
: eq(obligations.tenantId, session.tenantId);
return db.query.obligations.findMany({ where, orderBy: desc(obligations.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateObligationDto) {
const [obligation] = await db.insert(obligations).values({ ...dto, tenantId: session.tenantId }).returning();
return obligation;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateObligationDto) {
const [updated] = await db.update(obligations).set({ ...dto, updatedAt: new Date() })
.where(and(eq(obligations.id, id), eq(obligations.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Obligation not found');
return updated;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ObligationsController } from './obligations.controller';
@Module({ controllers: [ObligationsController] })
export class ObligationsModule {}

View file

@ -0,0 +1,80 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { outcomeReviews } from '../db/schema';
class CreateOutcomeReviewDto {
@IsString() title!: string;
@IsOptional() @IsUUID() decisionId?: string;
@IsOptional() @IsString() periodStart?: string;
@IsOptional() @IsString() periodEnd?: string;
@IsOptional() @IsString() actualOutcome?: string;
@IsOptional() @IsBoolean() @Type(() => Boolean) wasSuccessful?: boolean;
@IsOptional() @IsString() lessonsLearned?: string;
@IsOptional() @IsString() nextSteps?: string;
@IsOptional() @IsInt() @Min(1) @Max(10) @Type(() => Number) rating?: number;
}
class UpdateOutcomeReviewDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() actualOutcome?: string;
@IsOptional() @IsBoolean() @Type(() => Boolean) wasSuccessful?: boolean;
@IsOptional() @IsString() lessonsLearned?: string;
@IsOptional() @IsString() nextSteps?: string;
@IsOptional() @IsInt() @Min(1) @Max(10) @Type(() => Number) rating?: number;
}
@Controller('outcome-reviews')
export class OutcomeReviewsController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('decisionId') decisionId?: string,
) {
const tid = session.tenantId;
let where = eq(outcomeReviews.tenantId, tid) as ReturnType<typeof eq>;
if (decisionId) where = and(where, eq(outcomeReviews.decisionId, decisionId)) as typeof where;
return db.query.outcomeReviews.findMany({ where, orderBy: [desc(outcomeReviews.createdAt)], limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateOutcomeReviewDto) {
const [row] = await db.insert(outcomeReviews).values({
tenantId: session.tenantId,
title: dto.title,
decisionId: dto.decisionId ?? null,
periodStart: dto.periodStart ?? null,
periodEnd: dto.periodEnd ?? null,
actualOutcome: dto.actualOutcome,
wasSuccessful: dto.wasSuccessful ?? null,
lessonsLearned: dto.lessonsLearned,
nextSteps: dto.nextSteps,
rating: dto.rating ?? null,
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateOutcomeReviewDto,
) {
const upd: Record<string, unknown> = { updatedAt: new Date() };
if (dto.title !== undefined) upd.title = dto.title;
if (dto.actualOutcome !== undefined) upd.actualOutcome = dto.actualOutcome;
if (dto.wasSuccessful !== undefined) upd.wasSuccessful = dto.wasSuccessful;
if (dto.lessonsLearned !== undefined) upd.lessonsLearned = dto.lessonsLearned;
if (dto.nextSteps !== undefined) upd.nextSteps = dto.nextSteps;
if (dto.rating !== undefined) upd.rating = dto.rating;
const [row] = await db.update(outcomeReviews)
.set(upd)
.where(and(eq(outcomeReviews.id, id), eq(outcomeReviews.tenantId, session.tenantId)))
.returning();
return row;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { OutcomeReviewsController } from './outcome-reviews.controller';
@Module({ controllers: [OutcomeReviewsController] })
export class OutcomeReviewsModule {}

View file

@ -0,0 +1,56 @@
import { Body, Controller, Get, IsArray, IsNumber, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { Type } from 'class-transformer';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { pipelineDeals } from '../db/schema';
class CreateDealDto {
@IsString() title!: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsString() contactId?: string;
@IsOptional() @IsString() stage?: string;
@IsOptional() @IsNumber() @Type(() => Number) valueMinorUnits?: number;
@IsOptional() @IsString() currency?: string;
@IsOptional() @IsNumber() @Type(() => Number) probability?: number;
@IsOptional() @IsString() expectedCloseDate?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsArray() tags?: string[];
}
class UpdateDealDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() stage?: string;
@IsOptional() @IsNumber() @Type(() => Number) valueMinorUnits?: number;
@IsOptional() @IsNumber() @Type(() => Number) probability?: number;
@IsOptional() @IsString() expectedCloseDate?: string;
@IsOptional() @IsString() notes?: string;
}
@Controller('pipeline')
export class PipelineController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('stage') stage?: string) {
const where = stage
? and(eq(pipelineDeals.tenantId, session.tenantId), isNull(pipelineDeals.deletedAt), eq(pipelineDeals.stage, stage))
: and(eq(pipelineDeals.tenantId, session.tenantId), isNull(pipelineDeals.deletedAt));
return db.query.pipelineDeals.findMany({ where, orderBy: desc(pipelineDeals.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateDealDto) {
const [deal] = await db.insert(pipelineDeals).values({
...dto, tenantId: session.tenantId, tags: dto.tags ?? [],
}).returning();
return deal;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDealDto) {
const [updated] = await db.update(pipelineDeals).set({ ...dto, updatedAt: new Date() })
.where(and(eq(pipelineDeals.id, id), eq(pipelineDeals.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Deal not found');
return updated;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { PipelineController } from './pipeline.controller';
@Module({ controllers: [PipelineController] })
export class PipelineModule {}

View file

@ -0,0 +1,66 @@
import { Body, Controller, Get, IsArray, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { projects } from '../db/schema';
class CreateProjectDto {
@IsString() name!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() organizationId?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() priority?: string;
@IsOptional() @IsString() startDate?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsString() currency?: string;
@IsOptional() @IsArray() tags?: string[];
}
class UpdateProjectDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() priority?: string;
@IsOptional() @IsString() startDate?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsArray() tags?: string[];
}
@Controller('projects')
export class ProjectsController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
const where = status
? and(eq(projects.tenantId, session.tenantId), isNull(projects.deletedAt), eq(projects.status, status))
: and(eq(projects.tenantId, session.tenantId), isNull(projects.deletedAt));
return db.query.projects.findMany({ where, orderBy: desc(projects.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateProjectDto) {
const [project] = await db.insert(projects).values({
...dto,
tenantId: session.tenantId,
tags: dto.tags ?? [],
}).returning();
return project;
}
@Get(':id')
async getOne(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
const project = await db.query.projects.findFirst({
where: and(eq(projects.id, id), eq(projects.tenantId, session.tenantId), isNull(projects.deletedAt)),
});
if (!project) throw new NotFoundException('Project not found');
return project;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateProjectDto) {
const [updated] = await db.update(projects).set({ ...dto, updatedAt: new Date() })
.where(and(eq(projects.id, id), eq(projects.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Project not found');
return updated;
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ProjectsController } from './projects.controller';
@Module({ controllers: [ProjectsController] })
export class ProjectsModule {}

View file

@ -0,0 +1,68 @@
import { Body, Controller, Get, HttpCode, IsOptional, IsString, NotFoundException, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { risks } from '../db/schema';
class CreateRiskDto {
@IsString() title!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() probability?: string;
@IsOptional() @IsString() impact?: string;
@IsOptional() @IsString() mitigation?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() decisionId?: string;
@IsOptional() @IsString() reviewDueAt?: string;
}
class UpdateRiskDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() category?: string;
@IsOptional() @IsString() probability?: string;
@IsOptional() @IsString() impact?: string;
@IsOptional() @IsString() status?: string;
@IsOptional() @IsString() mitigation?: string;
@IsOptional() @IsString() owner?: string;
@IsOptional() @IsString() reviewDueAt?: string;
}
@Controller('risks')
export class RisksController {
@Get()
async list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
const where = status
? and(eq(risks.tenantId, session.tenantId), eq(risks.status, status))
: eq(risks.tenantId, session.tenantId);
return db.query.risks.findMany({ where, orderBy: desc(risks.createdAt), limit: 200 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateRiskDto) {
const [risk] = await db.insert(risks).values({
...dto,
tenantId: session.tenantId,
reviewDueAt: dto.reviewDueAt ? new Date(dto.reviewDueAt) : undefined,
}).returning();
return risk;
}
@Patch(':id')
async update(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRiskDto) {
const [updated] = await db.update(risks).set({
...dto,
updatedAt: new Date(),
reviewDueAt: dto.reviewDueAt !== undefined ? (dto.reviewDueAt ? new Date(dto.reviewDueAt) : null) : undefined,
}).where(and(eq(risks.id, id), eq(risks.tenantId, session.tenantId))).returning();
if (!updated) throw new NotFoundException('Risk not found');
return updated;
}
@HttpCode(204) @Post(':id/close')
async close(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.update(risks).set({ status: 'closed', updatedAt: new Date() })
.where(and(eq(risks.id, id), eq(risks.tenantId, session.tenantId)));
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { RisksController } from './risks.controller';
@Module({ controllers: [RisksController] })
export class RisksModule {}

View file

@ -0,0 +1,90 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { IsIn, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
import { and, desc, eq } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import { scenarios } from '../db/schema';
class CreateScenarioDto {
@IsString() title!: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsUUID() decisionId?: string;
@IsOptional() @IsIn(['low','medium','high']) probability?: string;
@IsOptional() @IsString() outcome?: string;
@IsOptional() @IsNumberString() financialImpactMinorUnits?: string;
@IsOptional() @IsString() financialImpactCurrency?: string;
@IsOptional() @IsIn(['positive','negative','neutral']) impactDirection?: string;
@IsOptional() @IsIn(['hypothetical','likely','confirmed','ruled_out']) status?: string;
}
class UpdateScenarioDto {
@IsOptional() @IsString() title?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsIn(['low','medium','high']) probability?: string;
@IsOptional() @IsString() outcome?: string;
@IsOptional() @IsNumberString() financialImpactMinorUnits?: string;
@IsOptional() @IsIn(['positive','negative','neutral']) impactDirection?: string;
@IsOptional() @IsIn(['hypothetical','likely','confirmed','ruled_out']) status?: string;
}
@Controller('scenarios')
export class ScenariosController {
@Get()
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('decisionId') decisionId?: string,
@Query('status') status?: string,
) {
const tid = session.tenantId;
let where = eq(scenarios.tenantId, tid) as ReturnType<typeof eq>;
if (decisionId) where = and(where, eq(scenarios.decisionId, decisionId)) as typeof where;
if (status) where = and(where, eq(scenarios.status, status)) as typeof where;
return db.query.scenarios.findMany({ where, orderBy: [desc(scenarios.createdAt)], limit: 500 });
}
@Post()
async create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateScenarioDto) {
const [row] = await db.insert(scenarios).values({
tenantId: session.tenantId,
title: dto.title,
description: dto.description,
decisionId: dto.decisionId ?? null,
probability: dto.probability ?? 'medium',
outcome: dto.outcome,
financialImpactMinorUnits: dto.financialImpactMinorUnits ?? null,
financialImpactCurrency: dto.financialImpactCurrency ?? 'RON',
impactDirection: dto.impactDirection ?? 'neutral',
status: dto.status ?? 'hypothetical',
}).returning();
return row;
}
@Patch(':id')
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateScenarioDto,
) {
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (dto.title !== undefined) updates.title = dto.title;
if (dto.description !== undefined) updates.description = dto.description;
if (dto.probability !== undefined) updates.probability = dto.probability;
if (dto.outcome !== undefined) updates.outcome = dto.outcome;
if (dto.financialImpactMinorUnits !== undefined) updates.financialImpactMinorUnits = dto.financialImpactMinorUnits;
if (dto.impactDirection !== undefined) updates.impactDirection = dto.impactDirection;
if (dto.status !== undefined) updates.status = dto.status;
const [row] = await db.update(scenarios)
.set(updates)
.where(and(eq(scenarios.id, id), eq(scenarios.tenantId, session.tenantId)))
.returning();
return row;
}
@Delete(':id')
async remove(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
await db.delete(scenarios)
.where(and(eq(scenarios.id, id), eq(scenarios.tenantId, session.tenantId)));
return { deleted: true };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { ScenariosController } from './scenarios.controller';
@Module({ controllers: [ScenariosController] })
export class ScenariosModule {}

View file

@ -0,0 +1,136 @@
import { Controller, Get, Query } from '@nestjs/common';
import { and, desc, eq, ilike, isNull, or } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { db } from '../db/client';
import {
organizations, tasks, goals, decisions, researchBriefs, savedSegments,
contacts, risks, projects, contracts, obligations, pipelineDeals,
} from '../db/schema';
interface SearchItem { type: string; id: string; title: string; subtitle?: string; route: string; }
interface SearchGroup { type: string; label: string; items: SearchItem[]; }
@Controller('search')
export class SearchController {
@Get()
async search(
@CurrentSession() session: AuthenticatedSession,
@Query('q') q = '',
) {
const term = q.trim();
if (!term || term.length < 2) return { q: term, groups: [] };
const tid = session.tenantId;
const pat = `%${term}%`;
const B = '/dashboard';
const [
orgs, tks, gls, decs, briefs, segs,
ctcs, rsks, projs, ctrcts, obligs, deals,
] = await Promise.all([
db.query.organizations.findMany({
where: and(eq(organizations.tenantId, tid), isNull(organizations.deletedAt), or(ilike(organizations.name, pat), ilike(organizations.domain, pat))),
columns: { id: true, name: true, country: true, domain: true }, limit: 5,
}),
db.query.tasks.findMany({
where: and(eq(tasks.tenantId, tid), ilike(tasks.title, pat)),
columns: { id: true, title: true, status: true }, limit: 5,
}),
db.query.goals.findMany({
where: and(eq(goals.tenantId, tid), or(ilike(goals.metric, pat), ilike(goals.horizon, pat))),
columns: { id: true, metric: true, target: true, horizon: true, status: true }, limit: 5,
}),
db.query.decisions.findMany({
where: and(eq(decisions.tenantId, tid), ilike(decisions.context, pat)),
columns: { id: true, context: true, selectedOption: true }, limit: 5,
}),
db.query.researchBriefs.findMany({
where: and(eq(researchBriefs.tenantId, tid), or(ilike(researchBriefs.title, pat), ilike(researchBriefs.organizationName, pat))),
columns: { id: true, title: true, organizationName: true }, limit: 4,
}),
db.query.savedSegments.findMany({
where: and(eq(savedSegments.tenantId, tid), ilike(savedSegments.name, pat)),
columns: { id: true, name: true }, limit: 4,
}),
db.query.contacts.findMany({
where: and(eq(contacts.tenantId, tid), isNull(contacts.deletedAt), or(ilike(contacts.fullName, pat), ilike(contacts.email, pat), ilike(contacts.role, pat))),
columns: { id: true, fullName: true, email: true, role: true }, limit: 5,
}),
db.query.risks.findMany({
where: and(eq(risks.tenantId, tid), or(ilike(risks.title, pat), ilike(risks.category, pat))),
columns: { id: true, title: true, status: true, probability: true, impact: true }, limit: 5,
}),
db.query.projects.findMany({
where: and(eq(projects.tenantId, tid), isNull(projects.deletedAt), or(ilike(projects.name, pat), ilike(projects.description, pat))),
columns: { id: true, name: true, status: true, priority: true }, limit: 5,
}),
db.query.contracts.findMany({
where: and(eq(contracts.tenantId, tid), isNull(contracts.deletedAt), ilike(contracts.title, pat)),
columns: { id: true, title: true, contractType: true, status: true }, limit: 4,
}),
db.query.obligations.findMany({
where: and(eq(obligations.tenantId, tid), ilike(obligations.title, pat)),
columns: { id: true, title: true, status: true, category: true }, limit: 4,
}),
db.query.pipelineDeals.findMany({
where: and(eq(pipelineDeals.tenantId, tid), isNull(pipelineDeals.deletedAt), ilike(pipelineDeals.title, pat)),
columns: { id: true, title: true, stage: true }, limit: 4,
}),
]);
const groups: SearchGroup[] = [
{
type: 'organization', label: 'Organizații',
items: orgs.map((o) => ({ type: 'organization', id: o.id, title: o.name, subtitle: o.country ?? o.domain ?? undefined, route: `${B}/organizations` })),
},
{
type: 'task', label: 'Taskuri',
items: tks.map((t) => ({ type: 'task', id: t.id, title: t.title, subtitle: t.status, route: `${B}/tasks` })),
},
{
type: 'goal', label: 'Obiective',
items: gls.map((g) => ({ type: 'goal', id: g.id, title: g.metric, subtitle: `${g.target} · ${g.horizon}`, route: `${B}/goals` })),
},
{
type: 'decision', label: 'Decizii',
items: decs.map((d) => ({ type: 'decision', id: d.id, title: (d.context ?? '').slice(0, 60), subtitle: d.selectedOption ?? 'În analiză', route: `${B}/decisions/workspace` })),
},
{
type: 'contact', label: 'Contacte',
items: ctcs.map((c) => ({ type: 'contact', id: c.id, title: c.fullName, subtitle: c.role ?? c.email ?? undefined, route: `${B}/crm` })),
},
{
type: 'project', label: 'Proiecte',
items: projs.map((p) => ({ type: 'project', id: p.id, title: p.name, subtitle: `${p.status} · ${p.priority}`, route: `${B}/projects` })),
},
{
type: 'risk', label: 'Riscuri',
items: rsks.map((r) => ({ type: 'risk', id: r.id, title: r.title, subtitle: `${r.probability}/${r.impact} · ${r.status}`, route: `${B}/risks` })),
},
{
type: 'contract', label: 'Contracte',
items: ctrcts.map((c) => ({ type: 'contract', id: c.id, title: c.title, subtitle: `${c.contractType} · ${c.status}`, route: `${B}/contracts` })),
},
{
type: 'obligation', label: 'Obligații',
items: obligs.map((o) => ({ type: 'obligation', id: o.id, title: o.title, subtitle: o.category, route: `${B}/obligations` })),
},
{
type: 'deal', label: 'Pipeline',
items: deals.map((d) => ({ type: 'deal', id: d.id, title: d.title, subtitle: d.stage, route: `${B}/pipeline` })),
},
{
type: 'research', label: 'Research',
items: briefs.map((b) => ({ type: 'research', id: b.id, title: b.title, subtitle: b.organizationName ?? undefined, route: `${B}/research` })),
},
{
type: 'segment', label: 'Segmente',
items: segs.map((s) => ({ type: 'segment', id: s.id, title: s.name, route: `${B}/segments` })),
},
].filter((g) => g.items.length > 0);
const totalCount = groups.reduce((s, g) => s + g.items.length, 0);
return { q: term, totalCount, groups };
}
}

View file

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
@Module({ controllers: [SearchController] })
export class SearchModule {}

View file

@ -1,43 +1,106 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { and, asc, desc, eq, isNull, inArray, lt, gte } from 'drizzle-orm';
import { CurrentSession } from '../auth/session.decorator';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { CreateTaskDto, TASK_STATUSES, UpdateTaskDto, type TaskStatusValue } from './dto';
import { TasksService } from './tasks.service';
import { BadRequestException } from '@nestjs/common';
import { db } from '../db/client';
import { tasks } from '../db/schema';
class CreateTaskDto {
@IsString() title!: string;
@IsOptional() @IsNumber() priority?: number;
@IsOptional() @IsString() dueAt?: string;
@IsOptional() @IsString() workspaceId?: string;
@IsOptional() @IsString() sourceEntityType?: string;
@IsOptional() @IsString() sourceEntityId?: string;
}
class UpdateTaskDto {
@IsOptional() @IsString() status?: string;
@IsOptional() @IsNumber() priority?: number;
@IsOptional() @IsString() dueAt?: string;
@IsOptional() @IsString() title?: string;
}
const ACTIVE_STATUSES = ['open', 'in_progress', 'blocked'] as const;
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
if (status && !TASK_STATUSES.includes(status as TaskStatusValue)) {
throw new BadRequestException(`status must be one of: ${TASK_STATUSES.join(', ')}`);
}
return this.tasksService.list(session, status as TaskStatusValue | undefined);
async list(
@CurrentSession() session: AuthenticatedSession,
@Query('status') status?: string,
@Query('limit') limitStr?: string,
) {
const limit = Math.min(Math.max(1, parseInt(limitStr ?? '100', 10) || 100), 500);
const statuses = status
? [status]
: [...ACTIVE_STATUSES];
return db.query.tasks.findMany({
where: and(
eq(tasks.tenantId, session.tenantId),
isNull(tasks.deletedAt),
inArray(tasks.status, statuses as any[]),
),
orderBy: [asc(tasks.priority), asc(tasks.dueAt), desc(tasks.createdAt)],
limit,
});
}
@Get(':id')
getById(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.tasksService.getById(session, id);
@Get('overdue')
async overdue(@CurrentSession() session: AuthenticatedSession) {
const now = new Date();
return db.query.tasks.findMany({
where: and(
eq(tasks.tenantId, session.tenantId),
isNull(tasks.deletedAt),
inArray(tasks.status, [...ACTIVE_STATUSES] as any[]),
lt(tasks.dueAt, now),
),
orderBy: [asc(tasks.dueAt)],
limit: 50,
});
}
@Post()
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateTaskDto) {
return this.tasksService.create(session, dto);
async create(
@CurrentSession() session: AuthenticatedSession,
@Body() dto: CreateTaskDto,
) {
const [created] = await db
.insert(tasks)
.values({
tenantId: session.tenantId,
workspaceId: dto.workspaceId ?? null,
ownerUserId: session.userId,
title: dto.title,
priority: dto.priority ?? 3,
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
sourceEntityType: dto.sourceEntityType ?? null,
sourceEntityId: dto.sourceEntityId ? dto.sourceEntityId as any : null,
})
.returning();
return created;
}
@Patch(':id')
update(
async update(
@CurrentSession() session: AuthenticatedSession,
@Param('id', ParseUUIDPipe) id: string,
@Param('id') id: string,
@Body() dto: UpdateTaskDto,
) {
return this.tasksService.update(session, id, dto);
}
@Delete(':id')
softDelete(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
return this.tasksService.softDelete(session, id);
const [updated] = await db
.update(tasks)
.set({
...(dto.status !== undefined ? { status: dto.status as any } : {}),
...(dto.priority !== undefined ? { priority: dto.priority } : {}),
...(dto.dueAt !== undefined ? { dueAt: dto.dueAt ? new Date(dto.dueAt) : null } : {}),
...(dto.title !== undefined ? { title: dto.title } : {}),
updatedAt: new Date(),
})
.where(and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId)))
.returning();
return updated;
}
}

View file

@ -1,11 +1,5 @@
import { Module } from '@nestjs/common';
import { EventsModule } from '../events/events.module';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [EventsModule],
controllers: [TasksController],
providers: [TasksService],
})
@Module({ controllers: [TasksController] })
export class TasksModule {}

View file

@ -0,0 +1,93 @@
-- Trade Intelligence — External API Data Store
-- Sources: UN Comtrade, IMF, World Bank, Eurostat
CREATE DATABASE IF NOT EXISTS trade_intelligence;
-- UN Comtrade: bilateral trade flows
CREATE TABLE IF NOT EXISTS trade_intelligence.comtrade
(
id UUID DEFAULT generateUUIDv4(),
period UInt32, -- YYYYMM or YYYY
period_type LowCardinality(String), -- M=monthly, A=annual
reporter_code UInt16,
reporter String,
partner_code UInt16,
partner String,
trade_flow LowCardinality(String), -- Import | Export | Re-Import | Re-Export
commodity_code String,
commodity String,
netweight_kg Float64,
trade_value_usd Float64,
qty Float64,
qty_unit LowCardinality(String),
fetched_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(fetched_at)
ORDER BY (period, reporter_code, partner_code, trade_flow, commodity_code)
PARTITION BY toString(intDiv(period, 100));
-- IMF World Economic Outlook
CREATE TABLE IF NOT EXISTS trade_intelligence.imf_weo
(
country_code LowCardinality(String),
country String,
indicator_code LowCardinality(String),
indicator String,
year UInt16,
value Nullable(Float64),
unit LowCardinality(String),
scale LowCardinality(String),
fetched_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(fetched_at)
ORDER BY (country_code, indicator_code, year);
-- World Bank Open Data
CREATE TABLE IF NOT EXISTS trade_intelligence.worldbank
(
country_code LowCardinality(String),
country String,
indicator_code LowCardinality(String),
indicator String,
year UInt16,
value Nullable(Float64),
fetched_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(fetched_at)
ORDER BY (country_code, indicator_code, year);
-- Eurostat: Comext trade data (intra+extra EU)
CREATE TABLE IF NOT EXISTS trade_intelligence.eurostat_comext
(
period UInt32,
reporter LowCardinality(String),
partner LowCardinality(String),
product String, -- CN8 code
flow LowCardinality(String), -- 1=Import 2=Export
value_eur Float64,
quantity_ton Float64,
stat_value Float64,
fetched_at DateTime DEFAULT now()
)
ENGINE = ReplacingMergeTree(fetched_at)
ORDER BY (period, reporter, partner, product, flow)
PARTITION BY toString(intDiv(period, 100));
-- Predictions: AI-generated trade forecasts stored here
CREATE TABLE IF NOT EXISTS trade_intelligence.predictions
(
id UUID DEFAULT generateUUIDv4(),
created_at DateTime DEFAULT now(),
commodity_code String,
commodity String,
reporter String,
partner String,
horizon_months UInt8,
predicted_value_usd Float64,
confidence Float32,
model LowCardinality(String),
features String, -- JSON
rationale String
)
ENGINE = MergeTree()
ORDER BY (created_at, commodity_code, reporter);