From 7d4c39d02cb971d48bfb20ded3bb4c9340e8854d Mon Sep 17 00:00:00 2001 From: open-orc-bot Date: Mon, 8 Jun 2026 22:14:42 +0000 Subject: [PATCH] feat: add analytics, blog, SEO, export, social, contact, and public profile features --- server/src/db/migrate.ts | 11 +- server/src/db/migrations/002_features.sql | 100 ++++++ server/src/index.ts | 18 + server/src/middleware/analytics.ts | 50 +++ server/src/middleware/validation.ts | 27 ++ server/src/routes/analytics.ts | 139 ++++++++ server/src/routes/blog.ts | 401 ++++++++++++++++++++++ server/src/routes/contact.ts | 46 +++ server/src/routes/export.ts | 144 ++++++++ server/src/routes/public.ts | 341 ++++++++++++++++++ server/src/routes/seo.ts | 117 +++++++ server/src/routes/social.ts | 152 ++++++++ shared/src/index.ts | 134 ++++++++ 13 files changed, 1677 insertions(+), 3 deletions(-) create mode 100644 server/src/db/migrations/002_features.sql create mode 100644 server/src/middleware/analytics.ts create mode 100644 server/src/middleware/validation.ts create mode 100644 server/src/routes/analytics.ts create mode 100644 server/src/routes/blog.ts create mode 100644 server/src/routes/contact.ts create mode 100644 server/src/routes/export.ts create mode 100644 server/src/routes/public.ts create mode 100644 server/src/routes/seo.ts create mode 100644 server/src/routes/social.ts diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 7c5f0b1..838e35a 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -6,9 +6,14 @@ export async function runMigrations(): Promise { 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); diff --git a/server/src/db/migrations/002_features.sql b/server/src/db/migrations/002_features.sql new file mode 100644 index 0000000..2a59281 --- /dev/null +++ b/server/src/db/migrations/002_features.sql @@ -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) +); diff --git a/server/src/index.ts b/server/src/index.ts index 420d6a8..f7604b3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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"; @@ -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); diff --git a/server/src/middleware/analytics.ts b/server/src/middleware/analytics.ts new file mode 100644 index 0000000..b9c4f43 --- /dev/null +++ b/server/src/middleware/analytics.ts @@ -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(); +} diff --git a/server/src/middleware/validation.ts b/server/src/middleware/validation.ts new file mode 100644 index 0000000..6be3695 --- /dev/null +++ b/server/src/middleware/validation.ts @@ -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(); + }; +} diff --git a/server/src/routes/analytics.ts b/server/src/routes/analytics.ts new file mode 100644 index 0000000..b822025 --- /dev/null +++ b/server/src/routes/analytics.ts @@ -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); + } + } +); diff --git a/server/src/routes/blog.ts b/server/src/routes/blog.ts new file mode 100644 index 0000000..024590a --- /dev/null +++ b/server/src/routes/blog.ts @@ -0,0 +1,401 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { PostData, PostTag, CreatePostRequest } from "@devfolio/shared"; +import { query } from "../config/db"; +import { authMiddleware } from "../middleware/auth"; +import { AppError } from "../middleware/error-handler"; + +export const blogRouter = Router(); + +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_]+/g, "-") + .replace(/-+/g, "-") + .trim(); +} + +async function resolveTags(tagNames: string[]): Promise { + const ids: string[] = []; + for (const name of tagNames) { + const trimmed = name.trim(); + if (!trimmed) continue; + const slug = slugify(trimmed); + const existing = await query("SELECT id FROM blog_tags WHERE slug = $1", [slug]); + if (existing.rows.length > 0) { + ids.push(existing.rows[0].id); + } else { + const result = await query( + "INSERT INTO blog_tags (name, slug) VALUES ($1, $2) RETURNING id", + [trimmed, slug] + ); + ids.push(result.rows[0].id); + } + } + return ids; +} + +async function attachTags(postId: string, tagIds: string[]): Promise { + await query("DELETE FROM blog_post_tags WHERE post_id = $1", [postId]); + for (const tagId of tagIds) { + await query( + "INSERT INTO blog_post_tags (post_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + [postId, tagId] + ); + } +} + +async function getTagsForPost(postId: string): Promise { + const result = await query( + `SELECT t.id, t.name, t.slug FROM blog_tags t + INNER JOIN blog_post_tags pt ON pt.tag_id = t.id + WHERE pt.post_id = $1`, + [postId] + ); + return result.rows.map((r) => ({ id: r.id, name: r.name, slug: r.slug })); +} + +function mapPost(row: Record, tags: PostTag[]): PostData { + return { + id: row.id as string, + authorId: row.author_id as string, + title: row.title as string, + slug: row.slug as string, + content: row.content as string, + excerpt: (row.excerpt as string) || undefined, + coverImage: (row.cover_image as string) || undefined, + isPublished: row.is_published as boolean, + publishedAt: row.published_at ? (row.published_at as Date).toISOString() : undefined, + tags, + createdAt: (row.created_at as Date).toISOString(), + updatedAt: (row.updated_at as Date).toISOString(), + }; +} + +// Public routes +blogRouter.get( + "/", + async (req: Request, res: Response, next: NextFunction) => { + try { + const page = Math.max(Number(req.query.page) || 1, 1); + const pageSize = Math.min(Math.max(Number(req.query.pageSize) || 10, 1), 50); + const tag = req.query.tag as string | undefined; + const offset = (page - 1) * pageSize; + + let whereClause = "WHERE p.is_published = true"; + const params: unknown[] = []; + let pIdx = 1; + + if (tag) { + whereClause += ` AND EXISTS (SELECT 1 FROM blog_post_tags pt + INNER JOIN blog_tags t ON t.id = pt.tag_id + WHERE pt.post_id = p.id AND t.slug = $${pIdx++})`; + params.push(tag); + } + + const countResult = await query( + `SELECT COUNT(*)::int as count FROM blog_posts p ${whereClause}`, + params + ); + const total = Number(countResult.rows[0].count); + + const postsResult = await query( + `SELECT p.id, p.author_id, p.title, p.slug, p.content, p.excerpt, p.cover_image, + p.is_published, p.published_at, p.created_at, p.updated_at, + u.name as author_name + FROM blog_posts p + LEFT JOIN users u ON u.id = p.author_id + ${whereClause} + ORDER BY p.published_at DESC NULLS LAST + LIMIT $${pIdx++} OFFSET $${pIdx++}`, + [...params, pageSize, offset] + ); + + const posts: PostData[] = []; + for (const row of postsResult.rows) { + const tags = await getTagsForPost(row.id); + posts.push(mapPost(row, tags)); + } + + res.json({ success: true, data: posts, page, pageSize, total }); + } catch (err) { + next(err); + } + } +); + +blogRouter.get( + "/tags", + async (_req: Request, res: Response, next: NextFunction) => { + try { + const result = await query( + `SELECT t.id, t.name, t.slug, COUNT(pt.post_id)::int as post_count + FROM blog_tags t + LEFT JOIN blog_post_tags pt ON pt.tag_id = t.id + GROUP BY t.id ORDER BY t.name` + ); + res.json({ success: true, data: result.rows }); + } catch (err) { + next(err); + } + } +); + +blogRouter.get( + "/slug/:slug", + async (req: Request, res: Response, next: NextFunction) => { + try { + const { slug } = req.params; + const result = await query( + `SELECT p.*, u.name as author_name FROM blog_posts p + LEFT JOIN users u ON u.id = p.author_id + WHERE p.slug = $1 AND p.is_published = true`, + [slug] + ); + if (result.rows.length === 0) { + throw new AppError("Post not found", 404); + } + const tags = await getTagsForPost(result.rows[0].id); + res.json({ success: true, data: mapPost(result.rows[0], tags) }); + } catch (err) { + next(err); + } + } +); + +blogRouter.get( + "/rss.xml", + async (_req: Request, res: Response, next: NextFunction) => { + try { + const result = await query( + `SELECT p.*, u.name as author_name FROM blog_posts p + LEFT JOIN users u ON u.id = p.author_id + WHERE p.is_published = true + ORDER BY p.published_at DESC LIMIT 20` + ); + + const baseUrl = process.env.BASE_URL || "http://localhost:3000"; + let xml = `\n\n\n`; + xml += `DevFolio Blog\n${escapeXml(baseUrl)}\n`; + xml += `Latest blog posts\n`; + + for (const row of result.rows) { + const pubDate = row.published_at + ? new Date(row.published_at).toUTCString() + : new Date(row.created_at).toUTCString(); + xml += `\n`; + xml += `${escapeXml(row.title)}\n`; + xml += `${escapeXml(baseUrl)}/blog/${escapeXml(row.slug)}\n`; + xml += `${escapeXml(row.excerpt || row.content.slice(0, 200))}\n`; + xml += `${pubDate}\n`; + xml += `${escapeXml(baseUrl)}/blog/${escapeXml(row.slug)}\n`; + xml += `\n`; + } + xml += `\n`; + + res.setHeader("Content-Type", "application/rss+xml"); + res.send(xml); + } catch (err) { + next(err); + } + } +); + +// Admin routes (authenticated) +blogRouter.get( + "/admin", + authMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const result = await query( + `SELECT p.*, u.name as author_name FROM blog_posts p + LEFT JOIN users u ON u.id = p.author_id + WHERE p.author_id = $1 + ORDER BY p.created_at DESC`, + [userId] + ); + const posts: PostData[] = []; + for (const row of result.rows) { + const tags = await getTagsForPost(row.id); + posts.push(mapPost(row, tags)); + } + res.json({ success: true, data: posts }); + } catch (err) { + next(err); + } + } +); + +blogRouter.get( + "/admin/:id", + authMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const { id } = req.params; + const result = await query( + "SELECT * FROM blog_posts WHERE id = $1 AND author_id = $2", + [id, userId] + ); + if (result.rows.length === 0) { + throw new AppError("Post not found", 404); + } + const tags = await getTagsForPost(result.rows[0].id); + res.json({ success: true, data: mapPost(result.rows[0], tags) }); + } catch (err) { + next(err); + } + } +); + +blogRouter.post( + "/admin", + authMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const { title, content, excerpt, coverImage, isPublished, tags: tagNames }: CreatePostRequest = req.body; + + if (!title || !content) { + throw new AppError("Title and content are required", 400); + } + + let slug = slugify(title); + const existing = await query("SELECT id FROM blog_posts WHERE slug = $1", [slug]); + if (existing.rows.length > 0) { + slug = `${slug}-${Date.now().toString(36)}`; + while ( + (await query("SELECT id FROM blog_posts WHERE slug = $1", [slug])).rows.length > 0 + ) { + slug = `${slugify(title)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + } + } + + const result = await query( + `INSERT INTO blog_posts (author_id, title, slug, content, excerpt, cover_image, is_published, published_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + userId, + title, + slug, + content, + excerpt || null, + coverImage || null, + isPublished || false, + isPublished ? new Date().toISOString() : null, + ] + ); + + if (tagNames && tagNames.length > 0) { + const tagIds = await resolveTags(tagNames); + await attachTags(result.rows[0].id, tagIds); + } + + const tags = await getTagsForPost(result.rows[0].id); + const post = mapPost(result.rows[0], tags); + res.status(201).json({ success: true, data: post }); + } catch (err) { + next(err); + } + } +); + +blogRouter.put( + "/admin/:id", + authMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const { id } = req.params; + const { title, content, excerpt, coverImage, isPublished, tags: tagNames } = req.body; + + const existing = await query( + "SELECT * FROM blog_posts WHERE id = $1 AND author_id = $2", + [id, userId] + ); + if (existing.rows.length === 0) { + throw new AppError("Post not found", 404); + } + + const setClauses: string[] = []; + const values: unknown[] = []; + let p = 1; + + if (title !== undefined) { + setClauses.push(`title = $${p++}`); + values.push(title); + } + if (content !== undefined) { + setClauses.push(`content = $${p++}`); + values.push(content); + } + if (excerpt !== undefined) { + setClauses.push(`excerpt = $${p++}`); + values.push(excerpt || null); + } + if (coverImage !== undefined) { + setClauses.push(`cover_image = $${p++}`); + values.push(coverImage || null); + } + if (isPublished !== undefined) { + setClauses.push(`is_published = $${p++}`); + values.push(isPublished); + if (isPublished && !existing.rows[0].published_at) { + setClauses.push(`published_at = NOW()`); + } else if (!isPublished) { + setClauses.push(`published_at = NULL`); + } + } + + if (setClauses.length > 0) { + setClauses.push(`updated_at = NOW()`); + values.push(id); + await query( + `UPDATE blog_posts SET ${setClauses.join(", ")} WHERE id = $${p}`, + values + ); + } + + if (tagNames !== undefined) { + const tagIds = await resolveTags(tagNames); + await attachTags(id, tagIds); + } + + const updated = await query("SELECT * FROM blog_posts WHERE id = $1", [id]); + const tags = await getTagsForPost(id); + const post = mapPost(updated.rows[0], tags); + res.json({ success: true, data: post }); + } catch (err) { + next(err); + } + } +); + +blogRouter.delete( + "/admin/:id", + authMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const { id } = req.params; + + const result = await query( + "DELETE FROM blog_posts WHERE id = $1 AND author_id = $2 RETURNING id", + [id, userId] + ); + if (result.rows.length === 0) { + throw new AppError("Post not found", 404); + } + + res.json({ success: true, data: { deleted: true } }); + } catch (err) { + next(err); + } + } +); diff --git a/server/src/routes/contact.ts b/server/src/routes/contact.ts new file mode 100644 index 0000000..eec095d --- /dev/null +++ b/server/src/routes/contact.ts @@ -0,0 +1,46 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { query } from "../config/db"; +import { AppError } from "../middleware/error-handler"; + +export const contactRouter = Router(); + +contactRouter.post( + "/", + async (req: Request, res: Response, next: NextFunction) => { + try { + const { name, email, subject, message, portfolioSlug } = req.body; + + const errors: string[] = []; + if (!name || typeof name !== "string" || name.trim().length < 2) { + errors.push("Name is required (min 2 chars)"); + } + if ( + !email || + typeof email !== "string" || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + ) { + errors.push("Valid email is required"); + } + if (!subject || typeof subject !== "string" || subject.trim().length < 3) { + errors.push("Subject is required (min 3 chars)"); + } + if (!message || typeof message !== "string" || message.trim().length < 10) { + errors.push("Message is required (min 10 chars)"); + } + + if (errors.length > 0) { + throw new AppError(errors.join("; "), 400); + } + + await query( + `INSERT INTO contact_messages (name, email, subject, message, portfolio_slug) + VALUES ($1, $2, $3, $4, $5)`, + [name.trim(), email.trim(), subject.trim(), message.trim(), portfolioSlug || null] + ); + + res.json({ success: true, data: { sent: true } }); + } catch (err) { + next(err); + } + } +); diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts new file mode 100644 index 0000000..c19b753 --- /dev/null +++ b/server/src/routes/export.ts @@ -0,0 +1,144 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { query } from "../config/db"; +import { authMiddleware } from "../middleware/auth"; +import { AppError } from "../middleware/error-handler"; + +export const exportRouter = Router(); + +exportRouter.use(authMiddleware); + +exportRouter.get( + "/portfolio/:slug", + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const { slug } = req.params; + const includeProjects = req.query.includeProjects === "true"; + const includeCV = req.query.includeCV === "true"; + const theme = (req.query.theme as string) || "light"; + + const result = await query( + `SELECT p.*, u.name as owner_name, u.email as owner_email + FROM portfolios p + INNER JOIN users u ON u.id = p.user_id + WHERE p.slug = $1 AND p.user_id = $2`, + [slug, userId] + ); + + if (result.rows.length === 0) { + throw new AppError("Portfolio not found", 404); + } + + const row = result.rows[0]; + const bg = theme === "dark" ? "#1a1a2e" : "#ffffff"; + const text = theme === "dark" ? "#e0e0e0" : "#333333"; + + let html = ` + + + + + ${row.title} - Portfolio + + + +

${row.owner_name}

+

${row.description || "Portfolio"}

+`; + + if (includeProjects) { + const projects = await query( + "SELECT * FROM projects WHERE portfolio_id = $1 ORDER BY sort_order", + [row.id] + ); + if (projects.rows.length > 0) { + html += `

Projects

`; + for (const p of projects.rows) { + html += `
+

${p.title}

+ ${p.description ? `

${p.description}

` : ""} + ${p.url ? `View Project` : ""} +
`; + } + html += `
`; + } + } + + if (includeCV) { + const cv = await query("SELECT * FROM cv_sections WHERE user_id = $1", [row.user_id]); + if (cv.rows.length > 0) { + const c = cv.rows[0]; + if (c.bio) { + html += `

About

${c.bio}

`; + } + + const skills = typeof c.skills === "string" ? JSON.parse(c.skills) : c.skills; + if (skills && skills.length > 0) { + html += `

Skills

`; + for (const s of skills) { + html += `
${s.name} — ${s.level}%
+
`; + } + html += `
`; + } + + const exp = typeof c.experience === "string" ? JSON.parse(c.experience) : c.experience; + if (exp && exp.length > 0) { + html += `

Experience

`; + for (const e of exp) { + html += `

${e.title} at ${e.company}

+

${e.startDate} — ${e.endDate || "Present"}

+ ${e.description ? `

${e.description}

` : ""}
`; + } + html += `
`; + } + + const edu = typeof c.education === "string" ? JSON.parse(c.education) : c.education; + if (edu && edu.length > 0) { + html += `

Education

`; + for (const e of edu) { + html += `

${e.degree} in ${e.field}

+

${e.school} — ${e.startDate} to ${e.endDate || "Present"}

`; + } + html += `
`; + } + } + } + + const socialLinks = await query( + "SELECT platform, url, label FROM social_links WHERE user_id = $1 ORDER BY sort_order", + [row.user_id] + ); + if (socialLinks.rows.length > 0) { + html += `

Links

`; + } + + html += ``; + + const filename = `${row.slug || "portfolio"}-export.html`; + res.setHeader("Content-Type", "text/html"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(html); + } catch (err) { + next(err); + } + } +); diff --git a/server/src/routes/public.ts b/server/src/routes/public.ts new file mode 100644 index 0000000..53198f8 --- /dev/null +++ b/server/src/routes/public.ts @@ -0,0 +1,341 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { query } from "../config/db"; +import { themeRouter } from "./theme"; + +export const publicRouter = Router(); + +publicRouter.get( + "/p/:slug", + async (req: Request, res: Response, next: NextFunction) => { + try { + const { slug } = req.params; + const host = req.headers.host || ""; + + let result = await query( + `SELECT p.*, u.name as owner_name, u.email as owner_email + FROM portfolios p + INNER JOIN users u ON u.id = p.user_id + WHERE (p.slug = $1 OR p.custom_domain = $2) AND p.published = true`, + [slug, host] + ); + + if (result.rows.length === 0) { + result = await query( + `SELECT p.*, u.name as owner_name, u.email as owner_email + FROM portfolios p + INNER JOIN users u ON u.id = p.user_id + WHERE p.slug = $1 AND p.published = true`, + [slug] + ); + } + + if (result.rows.length === 0) { + res.status(404).send(render404()); + return; + } + + const row = result.rows[0]; + const baseUrl = process.env.BASE_URL || `http://${host}`; + + const projects = await query( + "SELECT * FROM projects WHERE portfolio_id = $1 ORDER BY sort_order", + [row.id] + ); + + const cv = await query("SELECT * FROM cv_sections WHERE user_id = $1", [row.user_id]); + + const socialLinks = await query( + "SELECT platform, url, label FROM social_links WHERE user_id = $1 ORDER BY sort_order", + [row.user_id] + ); + + const template = row.template || "default"; + const themeColors = getThemeColors(row.theme); + const css = generateCSS(themeColors); + + let projectsHTML = ""; + if (projects.rows.length > 0) { + projectsHTML = `

Projects

`; + for (const p of projects.rows) { + projectsHTML += `
+ ${p.image ? `${p.title}` : ""} +
+

${p.title}

+ ${p.description ? `

${p.description}

` : ""} + ${p.url ? `View Project` : ""} +
+
`; + } + projectsHTML += `
`; + } + + let cvHTML = ""; + if (cv.rows.length > 0) { + const c = cv.rows[0]; + if (c.bio) { + cvHTML += `

About Me

${c.bio}

`; + } + const skills = typeof c.skills === "string" ? JSON.parse(c.skills) : c.skills; + if (skills && skills.length > 0) { + cvHTML += `

Skills

`; + for (const s of skills) { + cvHTML += `
+ ${s.name} +
+
`; + } + cvHTML += `
`; + } + const exp = typeof c.experience === "string" ? JSON.parse(c.experience) : c.experience; + if (exp && exp.length > 0) { + cvHTML += `

Experience

`; + for (const e of exp) { + cvHTML += `
+

${e.title}

+

${e.company}

+

${e.startDate} — ${e.endDate || "Present"}

+ ${e.description ? `

${e.description}

` : ""} +
`; + } + cvHTML += `
`; + } + const edu = typeof c.education === "string" ? JSON.parse(c.education) : c.education; + if (edu && edu.length > 0) { + cvHTML += `

Education

`; + for (const e of edu) { + cvHTML += `
+

${e.degree} in ${e.field}

+

${e.school}

+

${e.startDate} — ${e.endDate || "Present"}

+
`; + } + cvHTML += `
`; + } + } + + let socialHTML = ""; + if (socialLinks.rows.length > 0) { + socialHTML = `

Connect

`; + } + + let contactHTML = ""; + if (row.email) { + contactHTML = `

Contact

+
+ + + + + +
+
+
`; + } + + const html = ` + + + + + ${row.title} — ${row.owner_name} + + + + + + + + + + + +
+
+

${row.owner_name}

+

${row.title}

+ ${row.description ? `

${row.description}

` : ""} +
+ ${projectsHTML} + ${cvHTML} + ${socialHTML} + ${contactHTML} +
+

© ${new Date().getFullYear()} ${row.owner_name}. Powered by DevFolio.

+ + +`; + + res.setHeader("Cache-Control", "public, max-age=300"); + res.send(html); + } catch (err) { + next(err); + } + } +); + +publicRouter.get( + "/p/:slug/analytics", + async (req: Request, res: Response, next: NextFunction) => { + try { + const { slug } = req.params; + const portResult = await query( + "SELECT id FROM portfolios WHERE slug = $1 AND published = true", + [slug] + ); + if (portResult.rows.length === 0) { + res.status(404).json({ success: false, error: "Not found" }); + return; + } + + const totalViews = await query( + "SELECT COUNT(*)::int as count FROM analytics_events WHERE portfolio_id = $1", + [portResult.rows[0].id] + ); + res.json({ + success: true, + data: { totalViews: Number(totalViews.rows[0].count) }, + }); + } catch (err) { + next(err); + } + } +); + +function render404(): string { + return `404 - Not Found

404

Portfolio not found

Go Home
`; +} + +function getThemeColors(themeName: string): Record { + const themes: Record> = { + ocean: { primary: "#0077B6", secondary: "#00B4D8", bg: "#f0f9ff", text: "#03045E", accent: "#90E0EF", card: "#e0f2fe" }, + forest: { primary: "#2D6A4F", secondary: "#52B788", bg: "#f0fdf4", text: "#081C15", accent: "#95D5B2", card: "#dcfce7" }, + sunset: { primary: "#E85D04", secondary: "#F48C06", bg: "#fff7ed", text: "#6B2100", accent: "#FAA307", card: "#ffedd5" }, + midnight: { primary: "#7B2CBF", secondary: "#9D4EDD", bg: "#0f0020", text: "#E0AAFF", accent: "#C77DFF", card: "#1a0033" }, + }; + return themes[themeName] || themes.ocean; +} + +function generateCSS(colors: Record): string { + return ` + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: ${colors.bg}; color: ${colors.text}; line-height: 1.7; } + .container { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; } + .hero { text-align: center; padding: 3rem 0 2rem; } + .hero h1 { font-size: 2.5rem; color: ${colors.primary}; } + .subtitle { font-size: 1.3rem; color: ${colors.secondary}; margin-top: 0.5rem; } + .description { max-width: 600px; margin: 1rem auto 0; opacity: 0.85; font-size: 1.05rem; } + .section { margin: 2.5rem 0; } + .section h2 { font-size: 1.5rem; color: ${colors.primary}; margin-bottom: 1.25rem; + padding-bottom: 0.5rem; border-bottom: 2px solid ${colors.accent}; } + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1.25rem; } + .card { background: ${colors.card}; border-radius: 10px; overflow: hidden; + border: 1px solid ${colors.accent}; transition: transform 0.2s; } + .card:hover { transform: translateY(-2px); } + .card-img { width: 100%; height: 180px; object-fit: cover; } + .card-body { padding: 1.25rem; } + .card-body h3 { font-size: 1.1rem; color: ${colors.primary}; margin-bottom: 0.5rem; } + .card-body p { font-size: 0.95rem; opacity: 0.85; margin-bottom: 0.75rem; } + .btn { display: inline-block; background: ${colors.primary}; color: #fff; padding: 0.5rem 1.25rem; + border-radius: 6px; text-decoration: none; font-size: 0.9rem; font-weight: 500; + border: none; cursor: pointer; transition: opacity 0.2s; } + .btn:hover { opacity: 0.9; } + .skills { display: flex; flex-direction: column; gap: 1rem; } + .skill-item { display: flex; flex-direction: column; gap: 0.3rem; } + .skill-label { font-weight: 500; font-size: 0.95rem; } + .skill-bar { background: ${colors.accent}40; height: 8px; border-radius: 4px; overflow: hidden; } + .skill-fill { background: ${colors.primary}; height: 100%; border-radius: 4px; transition: width 0.3s; } + .timeline { display: flex; flex-direction: column; gap: 1.25rem; } + .timeline-item { padding-left: 1rem; border-left: 3px solid ${colors.accent}; } + .timeline-item h3 { color: ${colors.primary}; font-size: 1.1rem; } + .company { font-weight: 500; color: ${colors.secondary}; } + .dates { font-size: 0.85rem; opacity: 0.7; margin-bottom: 0.3rem; } + .social-links { display: flex; gap: 1rem; flex-wrap: wrap; } + .social-link { display: inline-flex; align-items: center; gap: 0.4rem; color: ${colors.primary}; + text-decoration: none; padding: 0.5rem 1rem; border: 1px solid ${colors.accent}; + border-radius: 6px; font-size: 0.9rem; transition: background 0.2s; } + .social-link:hover { background: ${colors.accent}40; } + .contact-form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 500px; } + .contact-form input, .contact-form textarea { padding: 0.7rem 1rem; border: 1px solid ${colors.accent}; + border-radius: 6px; font-size: 0.95rem; font-family: inherit; background: ${colors.card}; color: ${colors.text}; } + .contact-form input:focus, .contact-form textarea:focus { outline: none; border-color: ${colors.primary}; } + .bio { font-size: 1.05rem; max-width: 650px; } + footer { text-align: center; padding: 2rem; font-size: 0.85rem; opacity: 0.6; } + @media (max-width: 600px) { + .hero h1 { font-size: 1.8rem; } + .grid { grid-template-columns: 1fr; } + } + `; +} + +function getSocialIcon(platform: string): string { + const icons: Record = { + github: "☐", + twitter: "𝕏", + linkedin: "⚲", + website: "🌐", + email: "✉", + youtube: "▶", + instagram: "☼", + facebook: "❖", + }; + return icons[platform.toLowerCase()] || "🔗"; +} + +function getJsonLd(row: Record, projects: unknown[], baseUrl: string, slug: string): string { + const ld: Record = { + "@context": "https://schema.org", + "@type": "Person", + name: row.owner_name as string, + url: `${baseUrl}/p/${slug}`, + description: row.description as string, + subjectOf: [] as Record[], + }; + for (const p of projects as Array>) { + (ld.subjectOf as Array>).push({ + "@type": "CreativeWork", + name: p.title as string, + description: (p.description as string) || "", + }); + } + return JSON.stringify(ld); +} diff --git a/server/src/routes/seo.ts b/server/src/routes/seo.ts new file mode 100644 index 0000000..9aa4868 --- /dev/null +++ b/server/src/routes/seo.ts @@ -0,0 +1,117 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { query } from "../config/db"; + +export const seoRouter = Router(); + +seoRouter.get( + "/robots.txt", + async (_req: Request, res: Response, next: NextFunction) => { + try { + const baseUrl = process.env.BASE_URL || "http://localhost:3000"; + const content = `User-agent: * +Allow: / +Disallow: /api/ +Sitemap: ${baseUrl}/sitemap.xml`; + + res.setHeader("Content-Type", "text/plain"); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.send(content); + } catch (err) { + next(err); + } + } +); + +seoRouter.get( + "/sitemap.xml", + async (_req: Request, res: Response, next: NextFunction) => { + try { + const baseUrl = process.env.BASE_URL || "http://localhost:3000"; + + const portResult = await query( + "SELECT slug, updated_at FROM portfolios WHERE slug IS NOT NULL AND published = true" + ); + const blogResult = await query( + "SELECT slug, updated_at FROM blog_posts WHERE is_published = true" + ); + + let xml = `\n\n`; + xml += `${baseUrl}daily1.0\n`; + + for (const row of portResult.rows) { + xml += `${baseUrl}/p/${row.slug}${ + new Date(row.updated_at).toISOString().split("T")[0] + }weekly0.8\n`; + } + + for (const row of blogResult.rows) { + xml += `${baseUrl}/blog/${row.slug}${ + new Date(row.updated_at).toISOString().split("T")[0] + }monthly0.6\n`; + } + + xml += ``; + + res.setHeader("Content-Type", "application/xml"); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.send(xml); + } catch (err) { + next(err); + } + } +); + +seoRouter.get( + "/portfolios/:slug/jsonld", + async (req: Request, res: Response, next: NextFunction) => { + try { + const { slug } = req.params; + const baseUrl = process.env.BASE_URL || "http://localhost:3000"; + + const result = await query( + `SELECT p.*, u.name as owner_name, u.email as owner_email + FROM portfolios p + INNER JOIN users u ON u.id = p.user_id + WHERE p.slug = $1 AND p.published = true`, + [slug] + ); + + if (result.rows.length === 0) { + res.status(404).json({ success: false, error: "Portfolio not found" }); + return; + } + + const row = result.rows[0]; + + const jsonLd = { + "@context": "https://schema.org", + "@type": "Person", + name: row.owner_name, + description: row.description, + url: `${baseUrl}/p/${row.slug}`, + knowsAbout: [] as string[], + subjectOf: [] as Record[], + }; + + const projectsResult = await query( + "SELECT title, description, url FROM projects WHERE portfolio_id = $1 ORDER BY sort_order", + [row.id] + ); + + for (const p of projectsResult.rows) { + jsonLd.subjectOf.push({ + "@type": "CreativeWork", + name: p.title, + description: p.description || "", + url: p.url || `${baseUrl}/p/${row.slug}`, + }); + jsonLd.knowsAbout.push(p.title); + } + + res.setHeader("Cache-Control", "public, max-age=3600"); + res.json(jsonLd); + } catch (err) { + next(err); + } + } +); diff --git a/server/src/routes/social.ts b/server/src/routes/social.ts new file mode 100644 index 0000000..b4f8010 --- /dev/null +++ b/server/src/routes/social.ts @@ -0,0 +1,152 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { SocialLinkData } from "@devfolio/shared"; +import { query } from "../config/db"; +import { authMiddleware } from "../middleware/auth"; +import { AppError } from "../middleware/error-handler"; + +export const socialRouter = Router(); + +socialRouter.use(authMiddleware); + +socialRouter.get( + "/", + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const result = await query( + `SELECT id, user_id, platform, url, label, sort_order, created_at, updated_at + FROM social_links WHERE user_id = $1 ORDER BY sort_order`, + [userId] + ); + const data: SocialLinkData[] = result.rows.map(mapSocialLink); + res.json({ success: true, data }); + } catch (err) { + next(err); + } + } +); + +socialRouter.post( + "/", + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const { platform, url, label } = req.body; + + if (!platform || !url) { + throw new AppError("Platform and URL are required", 400); + } + + const maxOrder = await query( + "SELECT COALESCE(MAX(sort_order), -1) as max_order FROM social_links WHERE user_id = $1", + [userId] + ); + + const result = await query( + `INSERT INTO social_links (user_id, platform, url, label, sort_order) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, user_id, platform, url, label, sort_order, created_at, updated_at`, + [userId, platform, url, label || null, Number(maxOrder.rows[0].max_order) + 1] + ); + + const data: SocialLinkData = mapSocialLink(result.rows[0]); + res.status(201).json({ success: true, data }); + } catch (err) { + next(err); + } + } +); + +socialRouter.put( + "/:id", + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const { id } = req.params; + const { platform, url, label, sortOrder } = req.body; + + const existing = await query( + "SELECT id FROM social_links WHERE id = $1 AND user_id = $2", + [id, userId] + ); + if (existing.rows.length === 0) { + throw new AppError("Social link not found", 404); + } + + const setClauses: string[] = []; + const values: unknown[] = []; + let p = 1; + + if (platform !== undefined) { + setClauses.push(`platform = $${p++}`); + values.push(platform); + } + if (url !== undefined) { + setClauses.push(`url = $${p++}`); + values.push(url); + } + if (label !== undefined) { + setClauses.push(`label = $${p++}`); + values.push(label); + } + if (sortOrder !== undefined) { + setClauses.push(`sort_order = $${p++}`); + values.push(sortOrder); + } + + if (setClauses.length === 0) { + throw new AppError("No fields to update", 400); + } + + setClauses.push(`updated_at = NOW()`); + values.push(id); + + const result = await query( + `UPDATE social_links SET ${setClauses.join(", ")} + WHERE id = $${p} + RETURNING id, user_id, platform, url, label, sort_order, created_at, updated_at`, + values + ); + + const data: SocialLinkData = mapSocialLink(result.rows[0]); + res.json({ success: true, data }); + } catch (err) { + next(err); + } + } +); + +socialRouter.delete( + "/:id", + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user!.userId; + const { id } = req.params; + + const result = await query( + "DELETE FROM social_links WHERE id = $1 AND user_id = $2 RETURNING id", + [id, userId] + ); + if (result.rows.length === 0) { + throw new AppError("Social link not found", 404); + } + + res.json({ success: true, data: { deleted: true } }); + } catch (err) { + next(err); + } + } +); + +function mapSocialLink(row: Record): SocialLinkData { + return { + id: row.id as string, + userId: row.user_id as string, + platform: row.platform as string, + url: row.url as string, + label: (row.label as string) || undefined, + sortOrder: Number(row.sort_order), + createdAt: (row.created_at as Date).toISOString(), + updatedAt: (row.updated_at as Date).toISOString(), + }; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 6da617d..ed01071 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -102,3 +102,137 @@ export interface ThemePreset { colors: ThemeColors; fonts: ThemeFonts; } + +export interface ProjectData { + id: string; + portfolioId: string; + title: string; + description?: string; + url?: string; + image?: string; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +export interface CVSectionData { + id: string; + userId: string; + bio?: string; + skills: CVSkill[]; + experience: CVExperience[]; + education: CVEducation[]; + createdAt: string; + updatedAt: string; +} + +export interface CVSkill { + name: string; + level: number; +} + +export interface CVExperience { + title: string; + company: string; + startDate: string; + endDate?: string; + description?: string; +} + +export interface CVEducation { + school: string; + degree: string; + field: string; + startDate: string; + endDate?: string; +} + +export interface SocialLinkData { + id: string; + userId: string; + platform: string; + url: string; + label?: string; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +export interface ContactFormData { + name: string; + email: string; + subject: string; + message: string; + portfolioSlug?: string; +} + +export interface PostData { + id: string; + authorId: string; + title: string; + slug: string; + content: string; + excerpt?: string; + coverImage?: string; + isPublished: boolean; + publishedAt?: string; + tags: PostTag[]; + createdAt: string; + updatedAt: string; +} + +export interface PostTag { + id: string; + name: string; + slug: string; +} + +export interface CreatePostRequest { + title: string; + content: string; + excerpt?: string; + coverImage?: string; + isPublished?: boolean; + tags?: string[]; +} + +export interface AnalyticsSummary { + totalViews: number; + uniqueVisitors: number; + topPage: string; + todayViews: number; +} + +export interface ChartDataPoint { + date: string; + count: number; +} + +export interface PageCount { + path: string; + count: number; +} + +export interface ReferrerCount { + referrer: string; + count: number; +} + +export interface ExportOptions { + includeProjects?: boolean; + includeCV?: boolean; + theme?: string; +} + +export interface PublicPortfolio { + id: string; + slug: string; + title: string; + description: string; + template: string; + theme: string; + projects?: ProjectData[]; + cv?: CVSectionData; + socialLinks?: SocialLinkData[]; + customDomain?: string; +}