diff --git a/Dockerfile b/Dockerfile index 574e25b..5a8377f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/drizzle/0009_financial_intelligence.sql b/drizzle/0009_financial_intelligence.sql new file mode 100644 index 0000000..5ab3a61 --- /dev/null +++ b/drizzle/0009_financial_intelligence.sql @@ -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); diff --git a/drizzle/0010_ceo_os_features.sql b/drizzle/0010_ceo_os_features.sql new file mode 100644 index 0000000..af59abf --- /dev/null +++ b/drizzle/0010_ceo_os_features.sql @@ -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 +); diff --git a/drizzle/0011_contracts_pipeline.sql b/drizzle/0011_contracts_pipeline.sql new file mode 100644 index 0000000..1488558 --- /dev/null +++ b/drizzle/0011_contracts_pipeline.sql @@ -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 +); diff --git a/drizzle/0012_action_plans_scenarios.sql b/drizzle/0012_action_plans_scenarios.sql new file mode 100644 index 0000000..096d293 --- /dev/null +++ b/drizzle/0012_action_plans_scenarios.sql @@ -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 +); diff --git a/drizzle/0013_assumptions_outcomes.sql b/drizzle/0013_assumptions_outcomes.sql new file mode 100644 index 0000000..55d1963 --- /dev/null +++ b/drizzle/0013_assumptions_outcomes.sql @@ -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 +); diff --git a/drizzle/0014_data_sources.sql b/drizzle/0014_data_sources.sql new file mode 100644 index 0000000..6cf3a20 --- /dev/null +++ b/drizzle/0014_data_sources.sql @@ -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 +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 720753e..c0bb801 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/money-raise/ingest.sh b/money-raise/ingest.sh new file mode 100644 index 0000000..5d137bd --- /dev/null +++ b/money-raise/ingest.sh @@ -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! ===" diff --git a/money-raise/knowledge-base.jsonl b/money-raise/knowledge-base.jsonl new file mode 100644 index 0000000..fcbf766 --- /dev/null +++ b/money-raise/knowledge-base.jsonl @@ -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:** 18–24 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"} \ No newline at end of file diff --git a/money-raise/schema.sql b/money-raise/schema.sql new file mode 100644 index 0000000..44c5c0e --- /dev/null +++ b/money-raise/schema.sql @@ -0,0 +1,28 @@ +-- Money Raise Knowledge Base +-- Run: docker exec 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; diff --git a/n8n-workflows/daily-planner.json b/n8n-workflows/daily-planner.json new file mode 100644 index 0000000..ebdbe68 --- /dev/null +++ b/n8n-workflows/daily-planner.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/n8n-workflows/financial-brief.json b/n8n-workflows/financial-brief.json new file mode 100644 index 0000000..3aa7f1c --- /dev/null +++ b/n8n-workflows/financial-brief.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/n8n-workflows/gmail-bridge.json b/n8n-workflows/gmail-bridge.json new file mode 100644 index 0000000..f7a75f8 --- /dev/null +++ b/n8n-workflows/gmail-bridge.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/n8n-workflows/weekly-review.json b/n8n-workflows/weekly-review.json new file mode 100644 index 0000000..16a51c7 --- /dev/null +++ b/n8n-workflows/weekly-review.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/src/action-plans/action-plans.controller.ts b/src/action-plans/action-plans.controller.ts new file mode 100644 index 0000000..8e9bcb0 --- /dev/null +++ b/src/action-plans/action-plans.controller.ts @@ -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 = { 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 }; + } +} diff --git a/src/action-plans/action-plans.module.ts b/src/action-plans/action-plans.module.ts new file mode 100644 index 0000000..cf04b6c --- /dev/null +++ b/src/action-plans/action-plans.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { ActionPlansController } from './action-plans.controller'; + +@Module({ controllers: [ActionPlansController] }) +export class ActionPlansModule {} diff --git a/src/ai-requests/ai-requests.controller.ts b/src/ai-requests/ai-requests.controller.ts new file mode 100644 index 0000000..62c10b6 --- /dev/null +++ b/src/ai-requests/ai-requests.controller.ts @@ -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 }; + } +} diff --git a/src/ai-requests/ai-requests.module.ts b/src/ai-requests/ai-requests.module.ts new file mode 100644 index 0000000..b5fb8b7 --- /dev/null +++ b/src/ai-requests/ai-requests.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { AiRequestsController } from './ai-requests.controller'; + +@Module({ + controllers: [AiRequestsController], +}) +export class AiRequestsModule {} diff --git a/src/assumptions/assumptions.controller.ts b/src/assumptions/assumptions.controller.ts new file mode 100644 index 0000000..8ea4228 --- /dev/null +++ b/src/assumptions/assumptions.controller.ts @@ -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; + 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 = { 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 }; + } +} diff --git a/src/assumptions/assumptions.module.ts b/src/assumptions/assumptions.module.ts new file mode 100644 index 0000000..3d6b713 --- /dev/null +++ b/src/assumptions/assumptions.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { AssumptionsController } from './assumptions.controller'; + +@Module({ controllers: [AssumptionsController] }) +export class AssumptionsModule {} diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts new file mode 100644 index 0000000..3105eea --- /dev/null +++ b/src/audit/audit.controller.ts @@ -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, + }); + } +} diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts index 66552da..9848e42 100644 --- a/src/audit/audit.module.ts +++ b/src/audit/audit.module.ts @@ -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], }) diff --git a/src/briefing/briefing.controller.ts b/src/briefing/briefing.controller.ts index 9d861cf..8e73cd7 100644 --- a/src/briefing/briefing.controller.ts +++ b/src/briefing/briefing.controller.ts @@ -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, + }; } } diff --git a/src/briefing/briefing.module.ts b/src/briefing/briefing.module.ts index b93e7d1..8bf0160 100644 --- a/src/briefing/briefing.module.ts +++ b/src/briefing/briefing.module.ts @@ -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 {} diff --git a/src/common/guards/service-key.guard.ts b/src/common/guards/service-key.guard.ts new file mode 100644 index 0000000..c460a95 --- /dev/null +++ b/src/common/guards/service-key.guard.ts @@ -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(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; + } +} diff --git a/src/consents/consents.controller.ts b/src/consents/consents.controller.ts new file mode 100644 index 0000000..19a0960 --- /dev/null +++ b/src/consents/consents.controller.ts @@ -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 }; + } +} diff --git a/src/consents/consents.module.ts b/src/consents/consents.module.ts new file mode 100644 index 0000000..fce824f --- /dev/null +++ b/src/consents/consents.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { ConsentsController } from './consents.controller'; + +@Module({ controllers: [ConsentsController] }) +export class ConsentsModule {} diff --git a/src/contacts/contacts.controller.ts b/src/contacts/contacts.controller.ts new file mode 100644 index 0000000..b65d741 --- /dev/null +++ b/src/contacts/contacts.controller.ts @@ -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))); + } +} diff --git a/src/contacts/contacts.module.ts b/src/contacts/contacts.module.ts new file mode 100644 index 0000000..c9c8b2a --- /dev/null +++ b/src/contacts/contacts.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { ContactsController } from './contacts.controller'; + +@Module({ controllers: [ContactsController] }) +export class ContactsModule {} diff --git a/src/contracts/contracts.controller.ts b/src/contracts/contracts.controller.ts new file mode 100644 index 0000000..845f692 --- /dev/null +++ b/src/contracts/contracts.controller.ts @@ -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))); + } +} diff --git a/src/contracts/contracts.module.ts b/src/contracts/contracts.module.ts new file mode 100644 index 0000000..70a375e --- /dev/null +++ b/src/contracts/contracts.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { ContractsController } from './contracts.controller'; + +@Module({ controllers: [ContractsController] }) +export class ContractsModule {} diff --git a/src/data-sources/data-sources.controller.ts b/src/data-sources/data-sources.controller.ts new file mode 100644 index 0000000..da696c0 --- /dev/null +++ b/src/data-sources/data-sources.controller.ts @@ -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; + 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 = { 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; + } +} diff --git a/src/data-sources/data-sources.module.ts b/src/data-sources/data-sources.module.ts new file mode 100644 index 0000000..b590ef0 --- /dev/null +++ b/src/data-sources/data-sources.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { DataSourcesController } from './data-sources.controller'; + +@Module({ controllers: [DataSourcesController] }) +export class DataSourcesModule {} diff --git a/src/db/schema.ts b/src/db/schema.ts index 34a21f0..29d2825 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), +}); diff --git a/src/decisions/decisions.controller.ts b/src/decisions/decisions.controller.ts index a86e9f6..5e49722 100644 --- a/src/decisions/decisions.controller.ts +++ b/src/decisions/decisions.controller.ts @@ -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; } } diff --git a/src/decisions/decisions.module.ts b/src/decisions/decisions.module.ts index d105546..490aa33 100644 --- a/src/decisions/decisions.module.ts +++ b/src/decisions/decisions.module.ts @@ -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 {} diff --git a/src/documents/documents.controller.ts b/src/documents/documents.controller.ts new file mode 100644 index 0000000..cdf5943 --- /dev/null +++ b/src/documents/documents.controller.ts @@ -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, + }); + } +} diff --git a/src/documents/documents.module.ts b/src/documents/documents.module.ts new file mode 100644 index 0000000..cb91a41 --- /dev/null +++ b/src/documents/documents.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { DocumentsController } from './documents.controller'; + +@Module({ controllers: [DocumentsController] }) +export class DocumentsModule {} diff --git a/src/export/export.controller.ts b/src/export/export.controller.ts new file mode 100644 index 0000000..fcaaba9 --- /dev/null +++ b/src/export/export.controller.ts @@ -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 }; + } +} diff --git a/src/export/export.module.ts b/src/export/export.module.ts new file mode 100644 index 0000000..ccd61b5 --- /dev/null +++ b/src/export/export.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { ExportController } from './export.controller'; + +@Module({ controllers: [ExportController] }) +export class ExportModule {} diff --git a/src/external-data/external-data.controller.ts b/src/external-data/external-data.controller.ts new file mode 100644 index 0000000..0c30c75 --- /dev/null +++ b/src/external-data/external-data.controller.ts @@ -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); + } +} diff --git a/src/external-data/external-data.module.ts b/src/external-data/external-data.module.ts new file mode 100644 index 0000000..6b19a40 --- /dev/null +++ b/src/external-data/external-data.module.ts @@ -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 {} diff --git a/src/external-data/external-data.service.ts b/src/external-data/external-data.service.ts new file mode 100644 index 0000000..2aa2348 --- /dev/null +++ b/src/external-data/external-data.service.ts @@ -0,0 +1,106 @@ +import { BadGatewayException, Injectable } from '@nestjs/common'; + +const TIMEOUT_MS = 15_000; + +async function jsonFetch(url: string, opts: RequestInit = {}): Promise { + 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 = { + 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 să 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 }), + }); + } +} diff --git a/src/financial-intelligence/dto.ts b/src/financial-intelligence/dto.ts new file mode 100644 index 0000000..ec22004 --- /dev/null +++ b/src/financial-intelligence/dto.ts @@ -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[]; +} diff --git a/src/financial-intelligence/financial-intelligence.controller.ts b/src/financial-intelligence/financial-intelligence.controller.ts new file mode 100644 index 0000000..d786d96 --- /dev/null +++ b/src/financial-intelligence/financial-intelligence.controller.ts @@ -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 }; + } +} diff --git a/src/financial-intelligence/financial-intelligence.module.ts b/src/financial-intelligence/financial-intelligence.module.ts new file mode 100644 index 0000000..ffae855 --- /dev/null +++ b/src/financial-intelligence/financial-intelligence.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { FinancialIntelligenceController } from './financial-intelligence.controller'; + +@Module({ + controllers: [FinancialIntelligenceController], +}) +export class FinancialIntelligenceModule {} diff --git a/src/goals/goals.controller.ts b/src/goals/goals.controller.ts index 5dd2c7f..3e0a420 100644 --- a/src/goals/goals.controller.ts +++ b/src/goals/goals.controller.ts @@ -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; } } diff --git a/src/goals/goals.module.ts b/src/goals/goals.module.ts index 10acab3..db9b03c 100644 --- a/src/goals/goals.module.ts +++ b/src/goals/goals.module.ts @@ -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 {} diff --git a/src/main.ts b/src/main.ts index 0e9926c..798a20c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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()); diff --git a/src/modules/ai/ai.controller.ts b/src/modules/ai/ai.controller.ts new file mode 100644 index 0000000..220aaaa --- /dev/null +++ b/src/modules/ai/ai.controller.ts @@ -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 }; + } + } +} diff --git a/src/modules/ai/ai.module.ts b/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..8e3c0df --- /dev/null +++ b/src/modules/ai/ai.module.ts @@ -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 {} diff --git a/src/modules/ai/ai.service.ts b/src/modules/ai/ai.service.ts new file mode 100644 index 0000000..b8d4848 --- /dev/null +++ b/src/modules/ai/ai.service.ts @@ -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('HERMES_BASE_URL', 'http://152.53.112.35:8080'); + this.apiKey = config.get('HERMES_API_KEY', ''); + this.model = config.get('HERMES_MODEL', 'gemini-2.0-flash'); + } + + async chat(messages: HermesMessage[], maxTokens = 2048): Promise { + const url = `${this.baseUrl}/v1/chat/completions`; + const headers: Record = { '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 { + 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'); + } +} diff --git a/src/modules/ai/dto/ask.dto.ts b/src/modules/ai/dto/ask.dto.ts new file mode 100644 index 0000000..ccc4a1b --- /dev/null +++ b/src/modules/ai/dto/ask.dto.ts @@ -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 +} diff --git a/src/modules/intelligence/intelligence.controller.ts b/src/modules/intelligence/intelligence.controller.ts new file mode 100644 index 0000000..c3981e0 --- /dev/null +++ b/src/modules/intelligence/intelligence.controller.ts @@ -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 }) { + 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 }); + } +} diff --git a/src/modules/intelligence/intelligence.module.ts b/src/modules/intelligence/intelligence.module.ts new file mode 100644 index 0000000..7496a38 --- /dev/null +++ b/src/modules/intelligence/intelligence.module.ts @@ -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 {} diff --git a/src/modules/intelligence/intelligence.service.ts b/src/modules/intelligence/intelligence.service.ts new file mode 100644 index 0000000..93e104a --- /dev/null +++ b/src/modules/intelligence/intelligence.service.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface ChQueryResult { + data: Record[]; + rows: number; + elapsed: number; + error?: string; +} + +const SAFE_QUERIES: Record) => 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 { + 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[]; rows: number }; + return { data: json.data ?? [], rows: json.rows ?? 0, elapsed }; + } + + async safeQuery(queryKey: string, params: Record = {}): Promise { + 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 ?? '') }; + } +} diff --git a/src/modules/trade/trade-connector.service.ts b/src/modules/trade/trade-connector.service.ts new file mode 100644 index 0000000..5a1b2de --- /dev/null +++ b/src/modules/trade/trade-connector.service.ts @@ -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[]> { + 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[] }; + 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[]> { + // IMF API v3: https://www.imf.org/external/datamapper/api/v1/ + const rows: Record[] = []; + 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; + const values = (data.values as Record>>)?.[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[]> { + const rows: Record[] = []; + 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[] | 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[]> { + // 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; + // Parse SDMX-JSON structure + return this.parseEurostatResponse(data, params); + } catch (err: any) { + this.logger.warn(`Eurostat error: ${err.message}`); + return []; + } + } + + private parseEurostatResponse(data: Record, params: Record): Record[] { + // SDMX-JSON parsing — simplified + try { + const obs = (data as any)?.dataSets?.[0]?.observations ?? {}; + const rows: Record[] = []; + for (const [key, values] of Object.entries(obs)) { + rows.push({ ...params, observation_key: key, value: (values as number[])[0] }); + } + return rows; + } catch { return []; } + } +} diff --git a/src/modules/trade/trade.controller.ts b/src/modules/trade/trade.controller.ts new file mode 100644 index 0000000..e64acf5 --- /dev/null +++ b/src/modules/trade/trade.controller.ts @@ -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 }; + } +} diff --git a/src/modules/trade/trade.module.ts b/src/modules/trade/trade.module.ts new file mode 100644 index 0000000..45029bd --- /dev/null +++ b/src/modules/trade/trade.module.ts @@ -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 {} diff --git a/src/modules/webhooks/webhooks.controller.ts b/src/modules/webhooks/webhooks.controller.ts new file mode 100644 index 0000000..68f3e2a --- /dev/null +++ b/src/modules/webhooks/webhooks.controller.ts @@ -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() }; } +} diff --git a/src/modules/webhooks/webhooks.module.ts b/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 0000000..2803dea --- /dev/null +++ b/src/modules/webhooks/webhooks.module.ts @@ -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 {} diff --git a/src/modules/webhooks/webhooks.service.ts b/src/modules/webhooks/webhooks.service.ts new file mode 100644 index 0000000..2950805 --- /dev/null +++ b/src/modules/webhooks/webhooks.service.ts @@ -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)}`); + } +} diff --git a/src/obligations/obligations.controller.ts b/src/obligations/obligations.controller.ts new file mode 100644 index 0000000..7a7f5ea --- /dev/null +++ b/src/obligations/obligations.controller.ts @@ -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; + } +} diff --git a/src/obligations/obligations.module.ts b/src/obligations/obligations.module.ts new file mode 100644 index 0000000..3c69b4e --- /dev/null +++ b/src/obligations/obligations.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { ObligationsController } from './obligations.controller'; + +@Module({ controllers: [ObligationsController] }) +export class ObligationsModule {} diff --git a/src/outcome-reviews/outcome-reviews.controller.ts b/src/outcome-reviews/outcome-reviews.controller.ts new file mode 100644 index 0000000..a13e775 --- /dev/null +++ b/src/outcome-reviews/outcome-reviews.controller.ts @@ -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; + 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 = { 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; + } +} diff --git a/src/outcome-reviews/outcome-reviews.module.ts b/src/outcome-reviews/outcome-reviews.module.ts new file mode 100644 index 0000000..d21989b --- /dev/null +++ b/src/outcome-reviews/outcome-reviews.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { OutcomeReviewsController } from './outcome-reviews.controller'; + +@Module({ controllers: [OutcomeReviewsController] }) +export class OutcomeReviewsModule {} diff --git a/src/pipeline/pipeline.controller.ts b/src/pipeline/pipeline.controller.ts new file mode 100644 index 0000000..e2c7f81 --- /dev/null +++ b/src/pipeline/pipeline.controller.ts @@ -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; + } +} diff --git a/src/pipeline/pipeline.module.ts b/src/pipeline/pipeline.module.ts new file mode 100644 index 0000000..afd2351 --- /dev/null +++ b/src/pipeline/pipeline.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { PipelineController } from './pipeline.controller'; + +@Module({ controllers: [PipelineController] }) +export class PipelineModule {} diff --git a/src/projects/projects.controller.ts b/src/projects/projects.controller.ts new file mode 100644 index 0000000..87d1b27 --- /dev/null +++ b/src/projects/projects.controller.ts @@ -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; + } +} diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts new file mode 100644 index 0000000..da3bd12 --- /dev/null +++ b/src/projects/projects.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { ProjectsController } from './projects.controller'; + +@Module({ controllers: [ProjectsController] }) +export class ProjectsModule {} diff --git a/src/risks/risks.controller.ts b/src/risks/risks.controller.ts new file mode 100644 index 0000000..7288790 --- /dev/null +++ b/src/risks/risks.controller.ts @@ -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))); + } +} diff --git a/src/risks/risks.module.ts b/src/risks/risks.module.ts new file mode 100644 index 0000000..927d54e --- /dev/null +++ b/src/risks/risks.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { RisksController } from './risks.controller'; + +@Module({ controllers: [RisksController] }) +export class RisksModule {} diff --git a/src/scenarios/scenarios.controller.ts b/src/scenarios/scenarios.controller.ts new file mode 100644 index 0000000..102ca88 --- /dev/null +++ b/src/scenarios/scenarios.controller.ts @@ -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; + 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 = { 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 }; + } +} diff --git a/src/scenarios/scenarios.module.ts b/src/scenarios/scenarios.module.ts new file mode 100644 index 0000000..eb901f5 --- /dev/null +++ b/src/scenarios/scenarios.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { ScenariosController } from './scenarios.controller'; + +@Module({ controllers: [ScenariosController] }) +export class ScenariosModule {} diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts new file mode 100644 index 0000000..5e344db --- /dev/null +++ b/src/search/search.controller.ts @@ -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 }; + } +} diff --git a/src/search/search.module.ts b/src/search/search.module.ts new file mode 100644 index 0000000..b8ba9e9 --- /dev/null +++ b/src/search/search.module.ts @@ -0,0 +1,5 @@ +import { Module } from '@nestjs/common'; +import { SearchController } from './search.controller'; + +@Module({ controllers: [SearchController] }) +export class SearchModule {} diff --git a/src/tasks/tasks.controller.ts b/src/tasks/tasks.controller.ts index 9dfe4e0..671bf00 100644 --- a/src/tasks/tasks.controller.ts +++ b/src/tasks/tasks.controller.ts @@ -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; } } diff --git a/src/tasks/tasks.module.ts b/src/tasks/tasks.module.ts index f1437cf..dce36dd 100644 --- a/src/tasks/tasks.module.ts +++ b/src/tasks/tasks.module.ts @@ -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 {} diff --git a/trade-intelligence/schema.sql b/trade-intelligence/schema.sql new file mode 100644 index 0000000..02338f8 --- /dev/null +++ b/trade-intelligence/schema.sql @@ -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);