# Kubera Wealth Management

A premium venture capital firm website — built with vanilla HTML/CSS/JS on the
frontend, Node.js/Express + EJS on the backend, and MariaDB for storage.

**Phase 1** is the full public-facing marketing site (home, portfolio,
investment thesis, team, insights/blog, careers, events, contact, and a
founder funding-application flow), backed by a real database instead of
hardcoded content.

**Phase 2** adds a custom admin CMS at `/admin` — login-protected, role-based,
used to manage every piece of content Phase 1 renders (portfolio companies,
articles, team, jobs, events), plus review queues for founder applications,
job applications, contact messages, and newsletter subscribers, homepage
statistics, admin user management, and an audit log. See [Admin CMS](#admin-cms).

**Phase 3** adds a self-service founder portal at `/founder` — founders who
submit the "Apply for Funding" form automatically get a portal account
(activated via an emailed link) where they can track their application's
status and manage submitted documents, without emailing in. See
[Founder Portal](#founder-portal).

The investor portal is still out of scope — see [Roadmap](#roadmap).

## Stack

- **Frontend:** HTML5, CSS3 (no preprocessor, no build step), vanilla JS (ES2024). GSAP, Lenis, and Lucide are loaded via CDN `<script>` tags — everything else is hand-written, dependency-free JS.
- **Backend:** Node.js 20+, Express, EJS templates.
- **Database:** MariaDB (or MySQL 8+).
- **No React/Vue/Angular/Next.js. No webpack/vite/bundler. No Docker required.**

## Project Structure

```
KuberaWealthManagement/
├── server.js                    # Express app entrypoint
├── config/db.js                 # MariaDB connection pool
├── models/                      # SQL query modules (one per entity)
├── controllers/
│   ├── pages.controller.js       # public site page handlers
│   ├── api.controller.js         # public JSON API (forms, portfolio filter)
│   ├── admin/                    # one controller per admin section
│   └── founder/                  # founder portal auth + dashboard controllers
├── routes/
│   ├── pages.routes.js
│   ├── api.routes.js
│   ├── admin.routes.js           # everything under /admin
│   └── founder.routes.js         # everything under /founder
├── middleware/                   # csrf, rate limiting, admin/founder auth, uploads (multer)
├── utils/                        # seo/meta, markdown rendering, formatting, mailer
├── views/
│   ├── partials/                  # public site head/header/footer
│   ├── pages/                     # one .ejs per public route
│   ├── admin/                     # admin panel views (mirrors controllers/admin/)
│   └── founder/                   # founder portal views (mirrors controllers/founder/)
├── public/                       # served as static: css/, js/, images/, videos/, fonts/
│   └── images/uploads/            # admin-uploaded logos/covers/photos (public, gitignored)
├── uploads/                       # resumes/, decks/ (private, multer writes here, gitignored)
├── database/
│   ├── schema.sql                  # full DDL (companies, articles, team, jobs, events, admins, founders, logs, …)
│   ├── migrate.js                  # runs schema.sql against MariaDB
│   ├── seed.js                     # realistic public-site sample data
│   └── create-admin.js             # bootstrap/reset the first admin account
├── .env.example
└── README.md
```

## Local Development

### 1. Prerequisites

- Node.js 20+
- MariaDB 10.6+ (or MySQL 8+) running locally

### 2. Install dependencies

```bash
npm install
```

### 3. Configure environment

```bash
cp .env.example .env
```

Edit `.env` and set `DB_USER` / `DB_PASSWORD` / `DB_NAME` to match your local
MariaDB setup, and set `SESSION_SECRET` to a random string.

### 4. Create the database and load schema + seed data

```bash
# Create a database + app user (adjust as needed)
mysql -u root -p -e "
  CREATE DATABASE kubera_wealth CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  CREATE USER 'kubera_app'@'localhost' IDENTIFIED BY 'your-password';
  GRANT ALL PRIVILEGES ON kubera_wealth.* TO 'kubera_app'@'localhost';
  FLUSH PRIVILEGES;
"

npm run db:migrate   # creates all tables
npm run db:seed      # OPTIONAL — loads fictional demo data (18 portfolio companies, team, articles, jobs, events…), useful for trying the site locally
```

`db:seed` truncates and reloads the content tables (it does not touch the
`admins` table, so it's safe to re-run without losing admin accounts). **Skip
it entirely if you're setting up a real site** — use `npm run db:init`
instead (see [Deployment](#deployment-ubuntu-2404-apache2-mariadb)), which
loads only the sector/article taxonomy the site needs to function, with zero
fictional content.

### 5. Create your admin account

```bash
npm run db:create-admin
```

Prompts for name/email/password (or read from `ADMIN_NAME` / `ADMIN_EMAIL` /
`ADMIN_PASSWORD` env vars, useful for scripted deploys). Running it again for
the same email resets that account's password and role rather than erroring.

### 6. Run the app

```bash
npm run dev     # node --watch, restarts on file changes
# or
npm start
```

Visit `http://localhost:3000` for the public site, or `http://localhost:3000/admin/login` for the admin CMS.

## Environment Variables

See `.env.example` for the full list. Key ones:

| Variable | Purpose |
|---|---|
| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` | MariaDB connection |
| `SESSION_SECRET` | Signs session cookies — must be a long random string. **The app refuses to start with `NODE_ENV=production` if this is missing or still the insecure default.** |
| `SITE_URL` | Used to build canonical URLs, sitemap, Open Graph tags |
| `SMTP_*`, `NOTIFY_EMAIL` | Optional — if unset, form submissions still save to the DB but skip the notification email (and founder activation/reset emails log to the console instead of sending) |
| `CONTACT_EMAIL` | Shown on the Contact page and in structured data. Leave empty and that section just points people to the contact form instead of showing a fake address. |
| `SOCIAL_LINKEDIN_URL`, `SOCIAL_TWITTER_URL` | Shown in the footer and JSON-LD `sameAs`. Leave empty to omit rather than link to a placeholder that isn't really yours. |
| `MAX_UPLOAD_MB` | Cap on pitch deck / resume upload size (default 15MB) |

`ADMIN_NAME` / `ADMIN_EMAIL` / `ADMIN_PASSWORD` are not part of `.env` — they're optional one-off variables for `npm run db:create-admin` (used to script account creation without interactive prompts, e.g. in a deploy pipeline).

## Features Implemented (Phase 1 — Public Site)

- **Home** — full-bleed video/gradient hero with canvas particle field, animated gradient blobs, animated counters ($5B+ AUM, 150+ companies, 40+ unicorns, 18 countries), sector grid, featured portfolio, logo marquee, testimonials, insights preview, CTA.
- **Portfolio** — server-rendered grid + a JSON API (`GET /api/portfolio`) for live client-side filtering by sector/stage and full-text search, plus individual company detail pages.
- **Investment Thesis** — 12-sector grid (AI, Healthcare, Climate, FinTech, Defense, Agriculture, Infrastructure, Enterprise Software, Cybersecurity, Robotics, Space, Quantum Computing) with live portfolio counts, and an animated "how we invest" timeline.
- **Team** — grouped by department, hover-reveal bios and LinkedIn links.
- **Insights (blog)** — category/tag/search filtering, pagination, Markdown articles (rendered + sanitized server-side), reading-progress bar, share buttons, related articles.
- **Careers** — open roles from the DB, job detail pages, resume upload (PDF/Word) with drag-and-drop.
- **Events** — upcoming/past events, registration form (dedupes per email per event).
- **Contact** — form with FAQ accordion and location cards.
- **Apply for Funding** — multi-step founder application form (company info → business details → pitch deck/financials/business plan upload).
- **Newsletter** signup in the footer on every page.
- **Cross-cutting:** dark/light theme toggle, custom cursor, mobile nav drawer, mega menu, cookie consent, back-to-top, GSAP scroll-reveal (falls back to a plain `IntersectionObserver` if GSAP fails to load from the CDN), Lenis smooth scroll, sitemap.xml + robots.txt, JSON-LD Organization schema, Open Graph/Twitter cards on every page.

## Admin CMS

Everything the public site renders from the database can be managed at
`/admin` — no more hand-editing seed data. Log in at `/admin/login` with the
account you created via `npm run db:create-admin`.

**Content management** — full create/edit/delete for:
- Portfolio companies (with logo + cover image upload)
- Insights articles (Markdown editor, cover image, draft/published workflow, auto-computed reading time)
- Team members (with photo upload)
- Job postings
- Events (with cover image upload) + a read-only registrations list

**Review queues** (with inline status-update dropdowns):
- Founder applications — view submitted pitch deck/financials/business plan, move through `new → reviewing → meeting_scheduled → passed/invested`
- Job applications — view resume, move through `new → reviewing → interviewing → rejected/hired`
- Contact messages — mark read/replied/archived, reply via `mailto:`
- Newsletter subscribers — toggle active/unsubscribed, export CSV

**Settings** (super admin only for the last two):
- Homepage statistics editor (the $5B+/150+/40+/18 counters)
- Admin user management (create accounts, assign roles, activate/deactivate)
- Audit log of every admin action (who did what, when, from which IP)

**Roles:** `super_admin` (full access, including managing other admins),
`editor` (create/edit/delete content and update statuses), `viewer`
(read-only — every write route redirects viewers away, enforced server-side
in `routes/admin.routes.js`, not just hidden in the UI).

## Founder Portal

Every "Apply for Funding" submission at `/apply` automatically provisions a
portal account at `/founder` for that email — there's no separate signup
step. The founder gets an email with a link to activate it.

**How it works:**
- Submitting an application looks up or creates a row in the `founders`
  table (the login identity) and links it to that submission via
  `founder_applications.founder_id`. One founder can have several
  applications over time — they all show up on the same dashboard.
- If the account is new (or was never activated), the email contains a
  one-time `/founder/activate/:token` link — clicking it verifies the email
  and sets a password in one step. If the account already exists and is
  active, the email is just a "we received it, log in to track it" note.
- **Dashboard** (`/founder`) — every application the founder has submitted,
  each with a visual status track (Submitted → Under Review → Meeting
  Scheduled → Invested, or a distinct "Not Moving Forward" state for
  `passed`), the sector/stage/raise amount, and links to the submitted
  documents.
- **Document management** — while an application is still in the `new`
  status (before an admin has started reviewing it), the founder can
  re-upload the pitch deck, financials, or business plan; once review
  begins, documents lock to preserve what was actually evaluated.
- **Account** (`/founder/account`) — change password.
- **Forgot password** — always shows the same "if that email has portal
  access, we've sent a link" response whether or not the email matches an
  account, so the flow can't be used to enumerate who has applied.

Activation and reset links are one-time-use (the token is cleared the
moment it's consumed) and time-limited (7 days for activation, 2 hours for
password resets).

## Security

- Parameterized queries everywhere (`mysql2` named placeholders) — no string-concatenated SQL.
- CSRF protection via a session-bound double-submit token on every POST form, public and admin alike (`middleware/csrf.js`).
- `helmet` for security headers + a Content-Security-Policy (no inline `<script>` tags anywhere, including the admin panel).
- Rate limiting on all form/API endpoints, with a stricter limiter on `/admin/login` and `/founder/login` (`express-rate-limit`).
- `express-validator` input validation + escaping on every public form field.
- File uploads restricted by MIME type and size (`multer`), written with randomized filenames (no path traversal from user-supplied names). Admin image uploads (logos/photos/covers) are capped at 8MB and go to `public/images/uploads/`; founder/job document uploads are capped at 15MB and go to the private `uploads/` directory.
- Passwords hashed with `bcryptjs` (cost factor 12); admin and founder sessions are regenerated on login to prevent session fixation.
- Role-based access control enforced in route middleware, not just hidden in the UI — a `viewer` account cannot reach a write endpoint even by URL.
- Founder activation/password-reset tokens are single-use (cleared on consumption), time-limited, and the forgot-password flow never reveals whether an email has an account.
- Every admin create/update/delete/login/logout is recorded to the `logs` table with actor, IP, and timestamp.
- Session cookies: `httpOnly`, `sameSite=lax`, `secure` in production.
- Markdown article bodies are rendered with `marked` then sanitized with `sanitize-html` before being sent to the browser.
- Outbound email (founder activation/reset, internal contact-form alerts) fails loudly to the server console rather than silently — see the `SMTP_HOST` note in `.env.example`.

**Note on scale:** sessions use Express's in-memory `MemoryStore`, which is fine for the single-process deployment this README describes. If you ever run this behind a load balancer with multiple Node processes, switch to a shared session store (e.g. `express-mysql-session` against the same MariaDB database) — otherwise a logged-in admin's session will only exist on whichever process handled their login.

## Deployment (Ubuntu 24.04, Apache2, MariaDB)

Node apps don't run "inside" Apache the way PHP does — Apache sits in front
as a reverse proxy to a Node process kept alive by `systemd`. This is the
standard, production-grade way to run Node on Ubuntu + Apache.

### 1. Install prerequisites

```bash
sudo apt update
sudo apt install -y apache2 mariadb-server

# Node.js 20 LTS via NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs

sudo systemctl enable --now apache2
sudo systemctl enable --now mariadb
sudo mysql_secure_installation
```

### 2. Deploy the code

```bash
sudo mkdir -p /var/www/kubera-wealth-management
sudo chown $USER:$USER /var/www/kubera-wealth-management
# copy project files into /var/www/kubera-wealth-management (git clone, rsync, scp, etc.)

cd /var/www/kubera-wealth-management
npm install --omit=dev
cp .env.example .env
# edit .env: DB credentials, a real SESSION_SECRET (openssl rand -hex 32),
# SITE_URL=https://yourdomain.com, NODE_ENV=production, and optionally
# SMTP_*, CONTACT_EMAIL, SOCIAL_LINKEDIN_URL/SOCIAL_TWITTER_URL
```

### 3. Create the database

```bash
sudo mysql -e "
  CREATE DATABASE kubera_wealth CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  CREATE USER 'kubera_app'@'localhost' IDENTIFIED BY 'STRONG-RANDOM-PASSWORD';
  GRANT ALL PRIVILEGES ON kubera_wealth.* TO 'kubera_app'@'localhost';
  FLUSH PRIVILEGES;
"
npm run db:migrate
npm run db:init            # taxonomy only — no fictional companies/team/articles/etc.
npm run db:create-admin    # creates your first super_admin login for /admin
```

Do **not** run `npm run db:seed` on a real deployment — it loads fictional
demo content meant for trying the site locally. Add your real portfolio
companies, team, articles, jobs, and events through `/admin` once you've
logged in.

### 4. Run the app as a systemd service

Create `/etc/systemd/system/kubera-wealth-management.service`:

```ini
[Unit]
Description=Kubera Wealth Management website
After=network.target mariadb.service

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/kubera-wealth-management
EnvironmentFile=/var/www/kubera-wealth-management/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```bash
sudo chown -R www-data:www-data /var/www/kubera-wealth-management
sudo systemctl daemon-reload
sudo systemctl enable --now kubera-wealth-management
sudo systemctl status kubera-wealth-management
```

### 5. Apache reverse proxy VirtualHost

With `NODE_ENV=production`, the app automatically calls `app.set('trust
proxy', 1)`, so it correctly reads the real client IP and protocol from
Apache's `X-Forwarded-*` headers — this matters for rate limiting (otherwise
every visitor shares Apache's IP) and secure cookies (otherwise Express never
sees the request as HTTPS). No extra config needed here as long as
`NODE_ENV=production` is set in `.env`.

```bash
sudo a2enmod proxy proxy_http headers ssl rewrite
```

Create `/etc/apache2/sites-available/kubera-wealth-management.conf`:

```apache
<VirtualHost *:80>
    ServerName yourdomain.com
    ServerAlias www.yourdomain.com

    ProxyPreserveHost On
    ProxyPass /uploads !
    ProxyPass / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/

    # Serve uploaded files directly through Apache
    Alias /uploads /var/www/kubera-wealth-management/uploads
    <Directory /var/www/kubera-wealth-management/uploads>
        Require all granted
        Options -Indexes
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/kubera-wealth-management-error.log
    CustomLog ${APACHE_LOG_DIR}/kubera-wealth-management-access.log combined
</VirtualHost>
```

```bash
sudo a2ensite kubera-wealth-management.conf
sudo apache2ctl configtest
sudo systemctl reload apache2
```

### 6. HTTPS

```bash
sudo apt install -y certbot python3-certbot-apache
sudo certbot --apache -d yourdomain.com -d www.yourdomain.com
```

Certbot rewrites the VirtualHost to redirect HTTP → HTTPS and auto-renews via
a systemd timer.

## Content You Should Replace Before Launch

The database ships with zero fictional content when initialized via
`npm run db:init` (as opposed to `npm run db:seed`, which is for local
demos only — see [Deployment](#deployment-ubuntu-2404-apache2-mariadb)).
What's left to fill in:

- **Portfolio companies, team, articles, jobs, events** — add through `/admin` once you've logged in. `site_stats` (the homepage counters) start at zero — set real numbers via Settings > Homepage Stats.
- **Office address** — `views/pages/contact.ejs` has a `<!-- TODO -->` placeholder where a real address should go (no fake address ships by default).
- **Contact email / social links** — controlled by `CONTACT_EMAIL`, `SOCIAL_LINKEDIN_URL`, `SOCIAL_TWITTER_URL` in `.env`; each is simply omitted from the page until you set it, rather than showing a placeholder that looks real.
- `public/videos/hero-loop.mp4` and `public/images/hero-poster.jpg` — referenced by the homepage hero but not included (binary media isn't generated here); the hero degrades gracefully to its gradient/particle background without them.
- `public/images/og/default.jpg` — default Open Graph share image (referenced, not included).
- Your first admin login — run `npm run db:create-admin` yourself with your real name/email/password; none is created for you.

## Roadmap (not built yet)

The database schema already includes a forward-compatible `investors` table for:

- Investor portal (secure LP login, fund performance, capital calls, downloadable reports)
- Newsletter/email campaign sending (subscriber capture and CSV export already work in the admin CMS; composing and sending campaigns does not)
