diff --git a/docker-compose.yml b/docker-compose.yml index bd32c5b..a0ac73b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,13 +38,17 @@ services: build: ./nest-front restart: unless-stopped ports: - - "5173:5173" + - "80:80" + depends_on: + - api intra: build: ./nest-intra restart: unless-stopped ports: - "5174:5174" + depends_on: + - api volumes: db_data: diff --git a/nest-backend/src/app.ts b/nest-backend/src/app.ts index 5f68658..9cca0aa 100644 --- a/nest-backend/src/app.ts +++ b/nest-backend/src/app.ts @@ -10,6 +10,97 @@ import teamRouter from './routes/team.js'; const app = express(); +// ── Logger ───────────────────────────────────────────────────────────────────── +const R = '\x1b[0m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const BLUE = '\x1b[34m'; +const MAGENTA = '\x1b[35m'; +const CYAN = '\x1b[36m'; +const WHITE = '\x1b[37m'; +const BG_RED = '\x1b[41m'; +const BG_GREEN = '\x1b[42m'; +const BG_YELLOW = '\x1b[43m'; +const BG_BLUE = '\x1b[44m'; +const BG_MAGENTA = '\x1b[45m'; +const BG_CYAN = '\x1b[46m'; + +const METHOD_STYLE: Record = { + GET: `${BG_BLUE}${WHITE}${BOLD}`, + POST: `${BG_GREEN}${WHITE}${BOLD}`, + PUT: `${BG_YELLOW}${WHITE}${BOLD}`, + PATCH: `${BG_MAGENTA}${WHITE}${BOLD}`, + DELETE: `${BG_RED}${WHITE}${BOLD}`, +}; + +function methodBadge(method: string): string { + const style = METHOD_STYLE[method] ?? `${BG_CYAN}${WHITE}${BOLD}`; + return `${style} ${method.padEnd(6)} ${R}`; +} + +function statusBadge(code: number): string { + if (code < 300) return `${BG_GREEN}${WHITE}${BOLD} ${code} ${R}`; + if (code < 400) return `${BG_YELLOW}${WHITE}${BOLD} ${code} ${R}`; + return `${BG_RED}${WHITE}${BOLD} ${code} ${R}`; +} + +function prettyJson(value: unknown): string { + return JSON.stringify(value, (k, v) => k === 'password' ? '***' : v, 2) + .split('\n') + .map((line, i) => i === 0 ? line : ` ${DIM} ${line}${R}`) + .join('\n'); +} + +const SEP = `${DIM}${'─'.repeat(60)}${R}`; + +app.use((req, res, next) => { + const start = Date.now(); + const ts = new Date().toISOString().replace('T', ' ').slice(0, 23); + + // Skip health check noise + if (req.originalUrl === '/api/health') { next(); return; } + + const originalJson = res.json.bind(res); + let resBody: unknown; + res.json = (body) => { resBody = body; return originalJson(body); }; + + res.on('finish', () => { + const ms = Date.now() - start; + const userId = req.user?.userId ? `${CYAN}${req.user.userId.slice(0, 8)}…${R}` : `${DIM}anon${R}`; + const role = req.user?.role ? `${MAGENTA}${req.user.role}${R}` : `${DIM}-${R}`; + + const hasBody = ['POST', 'PUT', 'PATCH'].includes(req.method) + && req.body && Object.keys(req.body).length > 0; + + const lines: string[] = [ + SEP, + `${DIM}${ts}${R} ${methodBadge(req.method)} ${BOLD}${req.originalUrl}${R}`, + ` ${DIM}┌ user ${R} ${userId} ${DIM}role:${R} ${role}`, + ` ${DIM}└ status ${R} ${statusBadge(res.statusCode)} ${DIM}${ms}ms${R}`, + ]; + + if (hasBody) { + lines.push(` ${GREEN}↑ REQUEST BODY${R}`); + lines.push(` ${DIM} ${prettyJson(req.body)}${R}`); + } + + if (res.statusCode >= 400 && resBody) { + lines.push(` ${RED}↓ ERROR RESPONSE${R}`); + lines.push(` ${DIM} ${prettyJson(resBody)}${R}`); + } else if (res.statusCode < 300 && resBody && req.method !== 'GET') { + lines.push(` ${GREEN}↓ RESPONSE BODY${R}`); + lines.push(` ${DIM} ${prettyJson(resBody)}${R}`); + } + + console.log(lines.join('\n')); + }); + + next(); +}); + app.use(cors({ origin: [ 'http://localhost:5173', // nest-front dev @@ -37,7 +128,7 @@ app.use((_req, res) => res.status(404).json({ error: 'Not found' })); // Global error handler app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - console.error(err); + console.error(`${BG_RED}${WHITE}${BOLD} UNHANDLED ERROR ${R}`, err); res.status(500).json({ error: 'Internal server error' }); }); diff --git a/nest-backend/src/lib/prisma.ts b/nest-backend/src/lib/prisma.ts index 4e54f7a..3bc967f 100644 --- a/nest-backend/src/lib/prisma.ts +++ b/nest-backend/src/lib/prisma.ts @@ -1,5 +1,11 @@ import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); +const prisma = new PrismaClient({ + log: [ + { emit: 'stdout', level: 'query' }, + { emit: 'stdout', level: 'warn' }, + { emit: 'stdout', level: 'error' }, + ], +}); export default prisma; diff --git a/nest-front/Dockerfile b/nest-front/Dockerfile index 58348d6..8729d50 100644 --- a/nest-front/Dockerfile +++ b/nest-front/Dockerfile @@ -17,14 +17,8 @@ RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/nginx.conf.template +COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 5173 +EXPOSE 80 -# API_URL is the backend's public base URL used by nginx proxy_pass. -# Set this at runtime in Coolify, e.g. API_URL=https://api.crowmate.fr -ENV API_URL=http://localhost:3000 - -# Substitute ${API_URL} in the nginx template at container start, then launch nginx. -# The quoted variable list prevents envsubst from replacing nginx variables like $host. -CMD ["/bin/sh", "-c", "envsubst '${API_URL}' < /etc/nginx/nginx.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/nest-front/docker-compose.yml b/nest-front/docker-compose.yml index 0428972..09edf0e 100644 --- a/nest-front/docker-compose.yml +++ b/nest-front/docker-compose.yml @@ -2,5 +2,5 @@ services: nest-front: build: . ports: - - "5173:5173" + - "80:80" restart: unless-stopped diff --git a/nest-front/nginx.conf b/nest-front/nginx.conf index 3af20f7..54e1ef3 100644 --- a/nest-front/nginx.conf +++ b/nest-front/nginx.conf @@ -1,10 +1,14 @@ server { - listen 5173; + listen 80; root /usr/share/nginx/html; index index.html; + # Use Docker's embedded DNS resolver; defer resolution to request time + resolver 127.0.0.11 valid=30s; + location /api/ { - proxy_pass ${API_URL}/api/; + set $api_upstream http://api:3000; + proxy_pass $api_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/nest-front/src/components/shared/DevRoleSwitcher.tsx b/nest-front/src/components/shared/DevRoleSwitcher.tsx index a4627b8..9520c3c 100644 --- a/nest-front/src/components/shared/DevRoleSwitcher.tsx +++ b/nest-front/src/components/shared/DevRoleSwitcher.tsx @@ -1,8 +1,7 @@ import { useAuth } from '../../contexts/AuthContext'; -import type { UserRole } from '../../types'; /** - * Developer-only overlay to quickly switch user roles for testing. + * Developer-only overlay to quickly log in as test accounts. * Only visible in development mode. */ export function DevRoleSwitcher() { @@ -12,9 +11,8 @@ export function DevRoleSwitcher() { } function DevRoleSwitcherInner() { - const { user, isAuthenticated, devSetRole, login, logout } = useAuth(); + const { user, isAuthenticated, login, logout } = useAuth(); - const ROLES: UserRole[] = ['user', 'dev', 'com']; const DEV_ACCOUNTS = [ { label: 'Dev/Admin (Kestrel)', email: 'kestrel@crowmate.dev' }, { label: 'Com Staff (Vesper)', email: 'vesper@crowmate.dev' }, @@ -48,30 +46,10 @@ function DevRoleSwitcherInner() {
Logged as: {user?.username}
-
+
Role: {user?.role}
-
- {ROLES.map((r) => ( - - ))} -
-
@@ -262,44 +276,37 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo export default function BugReportPage() { const { user, isAuthenticated } = useAuth(); - const [bugs, setBugs] = useState(MOCK_BUGS); + const [bugs, setBugs] = useState([]); + const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState('all'); const [severityFilter, setSeverityFilter] = useState('all'); const [showForm, setShowForm] = useState(false); - // Separate: user's own bugs and all others, both filtered + const fetchBugs = useCallback(() => { + setLoading(true); + bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 }) + .then((res) => setBugs(res.data)) + .catch(() => setBugs([])) + .finally(() => setLoading(false)); + }, [statusFilter, severityFilter]); + + useEffect(() => { fetchBugs(); }, [fetchBugs]); + const { myBugs, otherBugs } = useMemo(() => { - const passes = (b: BugReport) => { - if (statusFilter !== 'all' && b.status !== statusFilter) return false; - if (severityFilter !== 'all' && b.severity !== severityFilter) return false; - return true; - }; const my: BugReport[] = []; const other: BugReport[] = []; bugs.forEach((b) => { - if (!passes(b)) return; if (user && b.submittedById === user.id) my.push(b); else other.push(b); }); return { myBugs: my, otherBugs: other }; - }, [bugs, statusFilter, severityFilter, user]); + }, [bugs, user]); - const handleNewReport = useCallback((data: BugReportFormData) => { - const newBug: BugReport = { - id: `bug${Date.now()}`, - uniqueCode: `HH-${String(bugs.length + 1).padStart(4, '0')}`, - ...data, - status: 'open', - submittedById: user?.id ?? 'unknown', - submittedByName: user?.username ?? 'You', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - notes: [], - meTooBugs: [], - }; + const handleNewReport = useCallback(async (data: BugReportFormData) => { + const newBug = await bugsApi.createBug(data); setBugs((prev) => [newBug, ...prev]); setShowForm(false); - }, [bugs.length, user]); + }, []); const openCount = bugs.filter((b) => b.status === 'open').length; const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length; @@ -373,68 +380,78 @@ export default function BugReportPage() { - {/* "Your Reports" section — only for logged-in users with their own bugs */} - {isAuthenticated && myBugs.length > 0 && ( -
-
- - ▶ Your Reports - - - {myBugs.length} - -
- {myBugs.map((bug) => ( - - ))} -
+ {loading && ( +
+ Loading reports... +
)} - {/* All other reports */} -
- {isAuthenticated && myBugs.length > 0 && ( -
- - All Reports - - - {otherBugs.length} - -
- )} + {!loading && ( + <> + {/* "Your Reports" section */} + {isAuthenticated && myBugs.length > 0 && ( +
+
+ + ▶ Your Reports + + + {myBugs.length} + +
+ {myBugs.map((bug) => ( + + ))} +
+ )} - {otherBugs.length === 0 && myBugs.length === 0 ? ( -
- No bug reports match the selected filters. -
- ) : otherBugs.length === 0 && isAuthenticated ? ( -
- No other reports match the selected filters. -
- ) : ( - otherBugs.map((bug) => ( - - )) - )} -
+ {/* All other reports */} +
+ {isAuthenticated && myBugs.length > 0 && ( +
+ + All Reports + + + {otherBugs.length} + +
+ )} + + {otherBugs.length === 0 && myBugs.length === 0 ? ( +
+ No bug reports match the selected filters. +
+ ) : otherBugs.length === 0 && isAuthenticated ? ( +
+ No other reports match the selected filters. +
+ ) : ( + otherBugs.map((bug) => ( + + )) + )} +
+ + )} ); } diff --git a/nest-front/src/pages/public/EventsPage.tsx b/nest-front/src/pages/public/EventsPage.tsx index cd2ae91..34a8017 100644 --- a/nest-front/src/pages/public/EventsPage.tsx +++ b/nest-front/src/pages/public/EventsPage.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback } from 'react'; -import { MOCK_EVENTS, MOCK_POLLS } from '../../data/mockData'; +import { useState, useCallback, useEffect } from 'react'; +import { eventsApi } from '../../utils/api'; import { useAuth } from '../../contexts/AuthContext'; import { formatDateTime } from '../../utils/format'; import type { EventPost, EventType, Poll, UserRole } from '../../types'; @@ -54,7 +54,7 @@ function PollCard({ poll, onVote }: { poll: Poll; onVote: (pollId: string, optio
{poll.options.map((option) => { const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0; - const userVoted = option.votedUserIds.includes(user?.id || ''); + const userVoted = option.votedUserIds.includes(user?.id ?? ''); return (
void; + onVote: (eventId: string, pollId: string, optionId: string) => void; }) { return (
{/* Poll if exists */} - {poll && } + {event.poll && ( + onVote(event.id, pollId, optionId)} + /> + )}
); } @@ -249,48 +252,28 @@ function EventCard({ export default function EventsPage() { const { user } = useAuth(); - // Filter to show only public events - const publicEvents = MOCK_EVENTS.filter((event) => event.isPublic); - const [events] = useState(publicEvents); - const [polls, setPolls] = useState(MOCK_POLLS); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + eventsApi.getEvents(true) + .then((res) => setEvents(res.events)) + .catch(() => setEvents([])) + .finally(() => setLoading(false)); + }, []); const handleVote = useCallback( - (pollId: string, optionId: string) => { + async (eventId: string, _pollId: string, optionId: string) => { if (!user) return; - setPolls((prevPolls) => - prevPolls.map((poll) => { - if (poll.id !== pollId) return poll; - - const hasVotedForOption = poll.options.some((opt) => - opt.votedUserIds.includes(user.id) - ); - - return { - ...poll, - options: poll.options.map((opt) => { - if (opt.id === optionId) { - // Add vote to this option - return { - ...opt, - votes: opt.votedUserIds.includes(user.id) ? opt.votes : opt.votes + 1, - votedUserIds: opt.votedUserIds.includes(user.id) - ? opt.votedUserIds - : [...opt.votedUserIds, user.id], - }; - } else if (!poll.allowMultipleVotes && hasVotedForOption) { - // Remove vote from other options if single vote - return { - ...opt, - votes: opt.votedUserIds.includes(user.id) ? opt.votes - 1 : opt.votes, - votedUserIds: opt.votedUserIds.filter((id) => id !== user.id), - }; - } - return opt; - }), - }; - }) - ); + try { + const updatedEvent = await eventsApi.vote(eventId, [optionId]); + setEvents((prev) => + prev.map((e) => (e.id === updatedEvent.id ? updatedEvent : e)) + ); + } catch { + // silently ignore + } }, [user] ); @@ -334,7 +317,7 @@ export default function EventsPage() { {/* Events Grid */}
- {events.length === 0 ? ( + {loading ? (
-
+
+ Loading events... +
+
+ ) : events.length === 0 ? ( +
+
No events available at the moment. Check back soon!
) : ( - events.map((event) => { - const poll = event.pollId ? polls.find((p) => p.id === event.pollId) : undefined; - return ; - }) + events.map((event) => ( + + )) )}
diff --git a/nest-front/src/pages/public/ForumPage.tsx b/nest-front/src/pages/public/ForumPage.tsx index 9229038..530f5d2 100644 --- a/nest-front/src/pages/public/ForumPage.tsx +++ b/nest-front/src/pages/public/ForumPage.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { MOCK_CATEGORIES, MOCK_THREADS } from '../../data/mockData'; +import { forumApi } from '../../utils/api'; import { timeAgo } from '../../utils/format'; import type { ForumCategory, ForumThread } from '../../types'; @@ -128,15 +128,43 @@ function CategoryCard({ category, threads }: { category: ForumCategory; threads: export default function ForumPage() { const [search, setSearch] = useState(''); + const [categories, setCategories] = useState([]); + const [threads, setThreads] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + setLoading(true); + + Promise.all([ + forumApi.getCategories(), + forumApi.getThreads({ limit: 200 }), + ]) + .then(([cats, threadRes]) => { + if (cancelled) return; + setCategories(cats); + setThreads(threadRes.data); + }) + .catch(() => { + if (cancelled) return; + setError('Failed to load forum. Please try again.'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, []); const filteredCategories = useMemo(() => { - if (!search.trim()) return MOCK_CATEGORIES; + if (!search.trim()) return categories; const q = search.toLowerCase(); - return MOCK_CATEGORIES.filter((cat) => + return categories.filter((cat) => cat.name.toLowerCase().includes(q) || - MOCK_THREADS.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q)) + threads.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q)) ); - }, [search]); + }, [search, categories, threads]); return (
@@ -173,15 +201,29 @@ export default function ForumPage() {
- {/* Categories */} - {filteredCategories.length === 0 ? ( + {loading && (
- No results found for "{search}" + Loading forum...
- ) : ( - filteredCategories.map((cat) => ( - - )) + )} + + {error && !loading && ( +
+ {error} +
+ )} + + {/* Categories */} + {!loading && !error && ( + filteredCategories.length === 0 ? ( +
+ No results found for "{search}" +
+ ) : ( + filteredCategories.map((cat) => ( + + )) + ) )}
); diff --git a/nest-front/src/pages/public/StudioPage.tsx b/nest-front/src/pages/public/StudioPage.tsx index b398088..55afe61 100644 --- a/nest-front/src/pages/public/StudioPage.tsx +++ b/nest-front/src/pages/public/StudioPage.tsx @@ -1,6 +1,16 @@ -import { TEAM_MEMBERS } from '../../data/mockData'; +import { useEffect, useState } from 'react'; +import { teamApi } from '../../utils/api'; +import type { TeamMember } from '../../types'; export default function StudioPage() { + const [members, setMembers] = useState([]); + + useEffect(() => { + teamApi.getMembers() + .then(setMembers) + .catch(() => { /* show empty state */ }); + }, []); + return (
{/* Header */} @@ -127,7 +137,7 @@ export default function StudioPage() { gap: '1.25rem', }} > - {TEAM_MEMBERS.map((member) => ( + {members.map((member) => (
{/* Avatar */} diff --git a/nest-front/src/pages/public/ThreadPage.tsx b/nest-front/src/pages/public/ThreadPage.tsx index 1c521ca..ed24d0e 100644 --- a/nest-front/src/pages/public/ThreadPage.tsx +++ b/nest-front/src/pages/public/ThreadPage.tsx @@ -1,23 +1,48 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { Link, useParams, Navigate } from 'react-router-dom'; -import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData'; +import { forumApi, ApiError } from '../../utils/api'; import { useAuth } from '../../contexts/AuthContext'; import { formatDateTime, timeAgo } from '../../utils/format'; +import type { ForumThread, ForumReply } from '../../types'; export default function ThreadPage() { const { id } = useParams<{ id: string }>(); const { user, isAuthenticated } = useAuth(); - const thread = MOCK_THREADS.find((t) => t.id === id); + const [thread, setThread] = useState(null); + const [replies, setReplies] = useState([]); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); - // Local state for new reply (stored in memory, not persisted) - const [replies, setReplies] = useState( - MOCK_REPLIES.filter((r) => r.threadId === id) - ); const [newReply, setNewReply] = useState(''); const [replyError, setReplyError] = useState(''); const [submitting, setSubmitting] = useState(false); + useEffect(() => { + if (!id) return; + let cancelled = false; + setLoading(true); + + forumApi.getThread(id) + .then((data) => { + if (cancelled) return; + const { replies: threadReplies, ...threadData } = data; + setThread(threadData); + setReplies(threadReplies); + }) + .catch((err) => { + if (cancelled) return; + if (err instanceof ApiError && err.status === 404) { + setNotFound(true); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [id]); + const handleReply = useCallback(async () => { if (!newReply.trim()) { setReplyError('Reply cannot be empty.'); @@ -30,23 +55,26 @@ export default function ThreadPage() { setReplyError(''); setSubmitting(true); - await new Promise((r) => setTimeout(r, 300)); + try { + const reply = await forumApi.createReply(id!, newReply.trim()); + setReplies((prev) => [...prev, reply]); + setNewReply(''); + } catch (err) { + setReplyError(err instanceof Error ? err.message : 'Failed to post reply.'); + } finally { + setSubmitting(false); + } + }, [newReply, id]); - const reply = { - id: `r${Date.now()}`, - content: newReply.trim(), - authorId: user!.id, - authorName: user!.username, - threadId: id!, - createdAt: new Date().toISOString(), - }; + if (loading) { + return ( +
+ Loading thread... +
+ ); + } - setReplies((prev) => [...prev, reply]); - setNewReply(''); - setSubmitting(false); - }, [newReply, user, id]); - - if (!thread) { + if (notFound || !thread) { return ; } @@ -203,7 +231,7 @@ export default function ThreadPage() {
); diff --git a/nest-intra/src/types/index.ts b/nest-intra/src/types/index.ts index db9ca04..472ee22 100644 --- a/nest-intra/src/types/index.ts +++ b/nest-intra/src/types/index.ts @@ -133,7 +133,8 @@ export interface EventPost { createdAt: string; updatedAt?: string; isPublic: boolean; // whether visible to community - pollId?: string; // reference to poll if type is 'poll' + pollId?: string | null; // reference to poll if type is 'poll' + poll?: Poll | null; // embedded poll data from API } export interface PollOption {