Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions server/src/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ export async function runMigrations(): Promise<void> {
const client = await pool.connect();

try {
const migrationPath = path.join(__dirname, "migrations", "001_init.sql");
const sql = fs.readFileSync(migrationPath, "utf-8");
await client.query(sql);
const migrationsDir = path.join(__dirname, "migrations");
const files = fs.readdirSync(migrationsDir)
.filter((f) => f.endsWith(".sql"))
.sort();
for (const file of files) {
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8");
await client.query(sql);
}
console.log("Migrations applied successfully");
} catch (err) {
console.error("Migration error:", err);
Expand Down
100 changes: 100 additions & 0 deletions server/src/db/migrations/002_features.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS slug VARCHAR(255);
ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS theme VARCHAR(100) NOT NULL DEFAULT 'default';
UPDATE portfolios SET slug = LOWER(REPLACE(TRIM(title), ' ', '-')) WHERE slug IS NULL;
ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS custom_domain VARCHAR(255);
CREATE INDEX IF NOT EXISTS idx_portfolios_slug ON portfolios(slug);

CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolios(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
url VARCHAR(2048),
image TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_projects_portfolio_id ON projects(portfolio_id);

CREATE TABLE IF NOT EXISTS cv_sections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
bio TEXT,
skills JSONB DEFAULT '[]'::jsonb,
experience JSONB DEFAULT '[]'::jsonb,
education JSONB DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS analytics_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
path VARCHAR(1024) NOT NULL,
referrer VARCHAR(2048),
user_agent TEXT,
ip_address VARCHAR(45),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
portfolio_id UUID REFERENCES portfolios(id) ON DELETE SET NULL,
event_type VARCHAR(50) NOT NULL DEFAULT 'pageview',
device_type VARCHAR(20),
browser VARCHAR(50),
visitor_hash VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_analytics_events_created_at ON analytics_events(created_at);
CREATE INDEX IF NOT EXISTS idx_analytics_events_user_id ON analytics_events(user_id);
CREATE INDEX IF NOT EXISTS idx_analytics_events_portfolio_id ON analytics_events(portfolio_id);
CREATE INDEX IF NOT EXISTS idx_analytics_events_visitor_hash ON analytics_events(visitor_hash);

CREATE TABLE IF NOT EXISTS social_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform VARCHAR(100) NOT NULL,
url VARCHAR(2048) NOT NULL,
label VARCHAR(255),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_social_links_user_id ON social_links(user_id);

CREATE TABLE IF NOT EXISTS contact_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_slug VARCHAR(255),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS blog_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
content TEXT NOT NULL DEFAULT '',
excerpt TEXT,
cover_image TEXT,
is_published BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_blog_posts_author_id ON blog_posts(author_id);
CREATE INDEX IF NOT EXISTS idx_blog_posts_slug ON blog_posts(slug);
CREATE INDEX IF NOT EXISTS idx_blog_posts_published ON blog_posts(is_published);

CREATE TABLE IF NOT EXISTS blog_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS blog_post_tags (
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES blog_tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
18 changes: 18 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { healthRouter } from "./routes/health";
import { authRouter } from "./routes/auth";
import { portfolioRouter } from "./routes/portfolio";
import { themeRouter, seedPresetThemes } from "./routes/theme";
import { analyticsRouter } from "./routes/analytics";
import { blogRouter } from "./routes/blog";
import { seoRouter } from "./routes/seo";
import { exportRouter } from "./routes/export";
import { contactRouter } from "./routes/contact";
import { socialRouter } from "./routes/social";
import { analyticsTracking } from "./middleware/analytics";
import { errorHandler } from "./middleware/error-handler";
import { runMigrations } from "./db/migrate";

Expand All @@ -13,13 +20,24 @@ dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;

app.set("trust proxy", 1);
app.use(cors());
app.use(express.json());
app.use(analyticsTracking);

app.use("/api/health", healthRouter);
app.use("/api/auth", authRouter);
app.use("/api/portfolios", portfolioRouter);
app.use("/api/themes", themeRouter);
app.use("/api/analytics", analyticsRouter);
app.use("/api/blog", blogRouter);
app.use("/api/contact", contactRouter);
app.use("/api/social", socialRouter);
app.use("/api/export", exportRouter);
app.use("/api", seoRouter);

import { publicRouter } from "./routes/public";
app.use("/", publicRouter);

app.use(errorHandler);

Expand Down
50 changes: 50 additions & 0 deletions server/src/middleware/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Request, Response, NextFunction } from "express";
import { query } from "../config/db";
import crypto from "crypto";

function getDeviceType(ua: string): string {
if (/mobile/i.test(ua)) return "mobile";
if (/tablet|ipad/i.test(ua)) return "tablet";
return "desktop";
}

function getBrowser(ua: string): string {
if (/edg/i.test(ua)) return "Edge";
if (/firefox/i.test(ua)) return "Firefox";
if (/safari/i.test(ua) && !/chrome/i.test(ua)) return "Safari";
if (/chrome/i.test(ua)) return "Chrome";
return "Other";
}

function hashVisitor(ip: string, ua: string): string {
return crypto.createHash("sha256").update(`${ip}|${ua}`).digest("hex").slice(0, 16);
}

export function analyticsTracking(
req: Request,
_res: Response,
next: NextFunction
): void {
try {
const userAgent = req.headers["user-agent"] || "";
const ip =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ||
req.socket.remoteAddress ||
"";
const referrer = (req.headers.referer || req.headers.referrer || "") as string;
const path = req.originalUrl || req.path;
const deviceType = getDeviceType(userAgent);
const browser = getBrowser(userAgent);
const visitorHash = hashVisitor(ip, userAgent);

query(
`INSERT INTO analytics_events (path, referrer, user_agent, ip_address, event_type, device_type, browser, visitor_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[path, referrer, userAgent, ip, "pageview", deviceType, browser, visitorHash]
).catch(() => {});
} catch {
// fire and forget
}

next();
}
27 changes: 27 additions & 0 deletions server/src/middleware/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Request, Response, NextFunction } from "express";
import { AppError } from "./error-handler";

interface ValidationRule {
field: string;
validate: (value: unknown) => boolean;
message: string;
}

export function validate(rules: ValidationRule[]) {
return (req: Request, _res: Response, next: NextFunction): void => {
const errors: string[] = [];

for (const rule of rules) {
const value = req.body[rule.field];
if (!rule.validate(value)) {
errors.push(rule.message);
}
}

if (errors.length > 0) {
throw new AppError(errors.join("; "), 400);
}

next();
};
}
139 changes: 139 additions & 0 deletions server/src/routes/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { Router, Request, Response, NextFunction } from "express";
import { AnalyticsSummary, ChartDataPoint, PageCount, ReferrerCount } from "@devfolio/shared";
import { query } from "../config/db";
import { authMiddleware } from "../middleware/auth";
import { AppError } from "../middleware/error-handler";

export const analyticsRouter = Router();

analyticsRouter.use(authMiddleware);

analyticsRouter.get(
"/summary",
async (_req: Request, res: Response, next: NextFunction) => {
try {
const totalResult = await query("SELECT COUNT(*)::int as count FROM analytics_events");
const totalViews = Number(totalResult.rows[0].count);

const uniqueResult = await query(
"SELECT COUNT(DISTINCT visitor_hash)::int as count FROM analytics_events"
);
const uniqueVisitors = Number(uniqueResult.rows[0].count);

const topPageResult = await query(
`SELECT path, COUNT(*)::int as count FROM analytics_events
GROUP BY path ORDER BY count DESC LIMIT 1`
);
const topPage = topPageResult.rows[0]?.path || "/";

const todayResult = await query(
`SELECT COUNT(*)::int as count FROM analytics_events
WHERE created_at >= CURRENT_DATE`
);
const todayViews = Number(todayResult.rows[0].count);

const summary: AnalyticsSummary = {
totalViews,
uniqueVisitors,
topPage,
todayViews,
};

res.json({ success: true, data: summary });
} catch (err) {
next(err);
}
}
);

analyticsRouter.get(
"/pageviews",
async (req: Request, res: Response, next: NextFunction) => {
try {
const days = Math.min(Math.max(Number(req.query.days) || 7, 1), 365);
const result = await query(
`SELECT TO_CHAR(d.date, 'YYYY-MM-DD') as date, COALESCE(COUNT(e.id), 0)::int as count
FROM generate_series(CURRENT_DATE - $1::int + 1, CURRENT_DATE, '1 day'::interval) d(date)
LEFT JOIN analytics_events e ON DATE(e.created_at) = d.date
GROUP BY d.date ORDER BY d.date`,
[days]
);
const data: ChartDataPoint[] = result.rows.map((r) => ({
date: r.date,
count: Number(r.count),
}));
res.json({ success: true, data });
} catch (err) {
next(err);
}
}
);

analyticsRouter.get(
"/toppages",
async (req: Request, res: Response, next: NextFunction) => {
try {
const days = Math.min(Math.max(Number(req.query.days) || 7, 1), 365);
const result = await query(
`SELECT path, COUNT(*)::int as count FROM analytics_events
WHERE created_at >= CURRENT_DATE - $1::int
GROUP BY path ORDER BY count DESC LIMIT 10`,
[days]
);
const data: PageCount[] = result.rows.map((r) => ({
path: r.path,
count: Number(r.count),
}));
res.json({ success: true, data });
} catch (err) {
next(err);
}
}
);

analyticsRouter.get(
"/referrers",
async (req: Request, res: Response, next: NextFunction) => {
try {
const days = Math.min(Math.max(Number(req.query.days) || 7, 1), 365);
const result = await query(
`SELECT COALESCE(NULLIF(referrer, ''), '(direct)') as referrer, COUNT(*)::int as count
FROM analytics_events
WHERE created_at >= CURRENT_DATE - $1::int
GROUP BY referrer ORDER BY count DESC LIMIT 10`,
[days]
);
const data: ReferrerCount[] = result.rows.map((r) => ({
referrer: r.referrer,
count: Number(r.count),
}));
res.json({ success: true, data });
} catch (err) {
next(err);
}
}
);

analyticsRouter.get(
"/visitors",
async (req: Request, res: Response, next: NextFunction) => {
try {
const days = Math.min(Math.max(Number(req.query.days) || 7, 1), 365);
const result = await query(
`SELECT TO_CHAR(d.date, 'YYYY-MM-DD') as date,
COALESCE(COUNT(DISTINCT e.visitor_hash), 0)::int as count
FROM generate_series(CURRENT_DATE - $1::int + 1, CURRENT_DATE, '1 day'::interval) d(date)
LEFT JOIN analytics_events e ON DATE(e.created_at) = d.date
GROUP BY d.date ORDER BY d.date`,
[days]
);
const data: ChartDataPoint[] = result.rows.map((r) => ({
date: r.date,
count: Number(r.count),
}));
res.json({ success: true, data });
} catch (err) {
next(err);
}
}
);
Loading