15 Commits

Author SHA1 Message Date
Thibault Pouch
d66424074c fix: update Dockerfile to compile seed script and run it before starting the application 2026-03-26 13:28:40 +01:00
Thibault Pouch
600026d90c fix: update environment variables and correct front service port in docker-compose.prod.yml 2026-03-26 13:20:31 +01:00
Thibault Pouch
f313c99696 feat: add Postman environment files for local and production setups 2026-03-26 11:34:08 +01:00
Thibault Pouch
3d21e41f88 fix: remove demo login hints from LoginPage form 2026-03-26 11:10:25 +01:00
Thibault Pouch
bc4d3e1dae fix: remove Prisma migrations directory from .gitignore 2026-03-26 11:10:22 +01:00
Thibault Pouch
80e26b85d5 feat: add initial database migration and update package dependencies 2026-03-26 11:10:17 +01:00
Thibault Pouch
0899ba1bc9 fix: update Dockerfile to run database migrations on startup and clean up package.json dependencies 2026-03-26 10:41:02 +01:00
Thibault Pouch
5268e7618b fix: update environment variable handling in Docker Compose files and improve contributing documentation 2026-03-19 21:27:56 +01:00
Thibault Pouch
3714200dc1 fix: update front service port mapping in production Docker Compose 2026-03-19 16:10:28 +01:00
Thibault Pouch
8f5d572632 feat: add production Docker Compose configuration for services 2026-03-19 15:43:43 +01:00
dffb1a6681 git : Merge pull request from feat/connect-front-to-backend into main
Reviewed-on: #1
Reviewed-by: Pierre1901 <pierre.ryssen@crowmate.fr>
2026-03-19 14:56:19 +01:00
Thibault Pouch
e68c2c32ba fix: integrate bug reporting API and enhance dashboard with recent bug statistics 2026-03-18 11:13:35 +01:00
Thibault Pouch
032b08bfb5 feat: implement forum moderation features with category management and thread operations 2026-03-18 10:59:07 +01:00
Thibault Pouch
bc9d93fe90 refactor: improve error handling and response structure for getThreads API function 2026-03-18 10:59:01 +01:00
Thibault Pouch
e7d1cda356 refactor: enhance authentication middleware to validate user existence and status before proceeding 2026-03-18 10:58:55 +01:00
22 changed files with 2024 additions and 78 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Host-side port for the API container (container port remains 3000)
API_HOST_PORT=3001
DATABASE_URL="postgresql://user:password@localhost:5432/nest_db"
JWT_SECRET="change_me_to_a_long_random_string"
PORT=3000
ADMIN_USERNAME="admin"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="change_me"
FRONT_ORIGIN="http://localhost:5173"
INTRA_ORIGIN="http://localhost:5174"

View File

@@ -49,7 +49,7 @@ npm install
### 3. Set Up Environment ### 3. Set Up Environment
Create `.env` files based on the examples in each project. See the main [README.md](./README.md) for required environment variables. Create a root `.env` file from `.env.example` (`cp .env.example .env`) and adjust values as needed. See the main [README.md](./README.md) for required environment variables.
### 4. Create a Branch ### 4. Create a Branch

View File

@@ -23,9 +23,11 @@ nest-intra/ # Staff-only internal portal (React + Vite)
### 1. Backend Setup ### 1. Backend Setup
```bash ```bash
cd nest-backend # from repository root
cp .env.example .env cp .env.example .env
# Edit .env with your database credentials # Edit .env with your credentials
cd nest-backend
npm install npm install
npm run db:push # Initialize database schema npm run db:push # Initialize database schema
npm run db:seed # Populate with sample data npm run db:seed # Populate with sample data
@@ -35,6 +37,7 @@ npm run dev # Start dev server (http://localhost:3000)
#### Backend Environment Variables #### Backend Environment Variables
```env ```env
API_HOST_PORT=3001
DATABASE_URL="postgresql://user:password@localhost:5432/nest_db" DATABASE_URL="postgresql://user:password@localhost:5432/nest_db"
JWT_SECRET="your-secret-key" JWT_SECRET="your-secret-key"
PORT=3000 PORT=3000

53
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,53 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: nest_db
POSTGRES_USER: nest_user
POSTGRES_PASSWORD: nest_password
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nest_user -d nest_db"]
interval: 5s
timeout: 5s
retries: 5
api:
image: git.crowmate.fr/crowmate/nest-api:latest
restart: unless-stopped
ports:
- "${API_HOST_PORT:-3001}:3000"
environment:
DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db
JWT_SECRET: ${JWT_SECRET}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
FRONT_ORIGIN: ${FRONT_ORIGIN:-http://localhost:5173}
INTRA_ORIGIN: ${INTRA_ORIGIN:-http://localhost:5174}
depends_on:
db:
condition: service_healthy
front:
image: git.crowmate.fr/crowmate/nest-front:latest
restart: unless-stopped
ports:
- "5173:80"
environment:
API_URL: http://api:3000
depends_on:
- api
intra:
image: git.crowmate.fr/crowmate/nest-intra:latest
restart: unless-stopped
ports:
- "5174:5174"
depends_on:
- api
volumes:
db_data:

View File

@@ -18,9 +18,9 @@ services:
build: ./nest-backend build: ./nest-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "${API_HOST_PORT:-3001}:3000"
env_file: env_file:
- ./nest-backend/.env - ./.env
environment: environment:
DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db
depends_on: depends_on:

View File

@@ -9,9 +9,6 @@ dist/
.env.local .env.local
.env.*.local .env.*.local
# Prisma
prisma/migrations/
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*

View File

@@ -10,6 +10,7 @@ RUN npm ci
COPY . . COPY . .
RUN npx prisma generate RUN npx prisma generate
RUN npm run build RUN npm run build
RUN npx tsc --module commonjs --target ES2022 --esModuleInterop --skipLibCheck --outDir dist prisma/seed.ts
FROM node:22-slim FROM node:22-slim
@@ -28,4 +29,4 @@ COPY prisma ./prisma
EXPOSE 3000 EXPOSE 3000
CMD ["node", "dist/index.js"] CMD ["sh", "-c", "npx prisma migrate deploy && node dist/seed.js && node dist/index.js"]

View File

@@ -18,7 +18,9 @@ services:
build: . build: .
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "${API_HOST_PORT:-3001}:3000"
env_file:
- ../.env
environment: environment:
DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db
JWT_SECRET: ${JWT_SECRET:-change_me_in_production} JWT_SECRET: ${JWT_SECRET:-change_me_in_production}

View File

@@ -864,7 +864,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,

View File

@@ -0,0 +1,254 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('user', 'dev', 'com');
-- CreateEnum
CREATE TYPE "BugSeverity" AS ENUM ('low', 'medium', 'high', 'critical');
-- CreateEnum
CREATE TYPE "BugStatus" AS ENUM ('open', 'in_progress', 'resolved', 'closed');
-- CreateEnum
CREATE TYPE "EventType" AS ENUM ('announcement', 'update', 'milestone', 'poll');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'user',
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isBanned" BOOLEAN NOT NULL DEFAULT false,
"avatarUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ForumCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"icon" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ForumCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ForumThread" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"isLocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"authorId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
CONSTRAINT "ForumThread_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ForumReply" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"authorId" TEXT NOT NULL,
"threadId" TEXT NOT NULL,
CONSTRAINT "ForumReply_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BugReport" (
"id" TEXT NOT NULL,
"uniqueCode" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"stepsToReproduce" TEXT NOT NULL,
"severity" "BugSeverity" NOT NULL,
"gameVersion" TEXT NOT NULL,
"screenshotUrl" TEXT,
"status" "BugStatus" NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"submittedById" TEXT NOT NULL,
"assignedToId" TEXT,
CONSTRAINT "BugReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BugComment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"bugReportId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
CONSTRAINT "BugComment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BugReportNote" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"bugReportId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
CONSTRAINT "BugReportNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MeTooBug" (
"userId" TEXT NOT NULL,
"bugReportId" TEXT NOT NULL,
CONSTRAINT "MeTooBug_pkey" PRIMARY KEY ("userId","bugReportId")
);
-- CreateTable
CREATE TABLE "StaffPost" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"authorId" TEXT NOT NULL,
CONSTRAINT "StaffPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventPost" (
"id" TEXT NOT NULL,
"type" "EventType" NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"authorId" TEXT NOT NULL,
CONSTRAINT "EventPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Poll" (
"id" TEXT NOT NULL,
"question" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"endsAt" TIMESTAMP(3),
"allowMultipleVotes" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"eventId" TEXT NOT NULL,
CONSTRAINT "Poll_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PollOption" (
"id" TEXT NOT NULL,
"text" TEXT NOT NULL,
"pollId" TEXT NOT NULL,
CONSTRAINT "PollOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PollVote" (
"userId" TEXT NOT NULL,
"pollOptionId" TEXT NOT NULL,
CONSTRAINT "PollVote_pkey" PRIMARY KEY ("userId","pollOptionId")
);
-- CreateTable
CREATE TABLE "SiteSettings" (
"id" INTEGER NOT NULL DEFAULT 1,
"forumEnabled" BOOLEAN NOT NULL DEFAULT true,
"bugsEnabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamMember" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL,
"bio" TEXT,
"avatarInitials" TEXT NOT NULL,
"twitterHandle" TEXT,
"githubHandle" TEXT,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "BugReport_uniqueCode_key" ON "BugReport"("uniqueCode");
-- CreateIndex
CREATE UNIQUE INDEX "Poll_eventId_key" ON "Poll"("eventId");
-- AddForeignKey
ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ForumCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForumReply" ADD CONSTRAINT "ForumReply_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForumReply" ADD CONSTRAINT "ForumReply_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "ForumThread"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugReport" ADD CONSTRAINT "BugReport_submittedById_fkey" FOREIGN KEY ("submittedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugReport" ADD CONSTRAINT "BugReport_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugComment" ADD CONSTRAINT "BugComment_bugReportId_fkey" FOREIGN KEY ("bugReportId") REFERENCES "BugReport"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugComment" ADD CONSTRAINT "BugComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugReportNote" ADD CONSTRAINT "BugReportNote_bugReportId_fkey" FOREIGN KEY ("bugReportId") REFERENCES "BugReport"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugReportNote" ADD CONSTRAINT "BugReportNote_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MeTooBug" ADD CONSTRAINT "MeTooBug_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MeTooBug" ADD CONSTRAINT "MeTooBug_bugReportId_fkey" FOREIGN KEY ("bugReportId") REFERENCES "BugReport"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffPost" ADD CONSTRAINT "StaffPost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventPost" ADD CONSTRAINT "EventPost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Poll" ADD CONSTRAINT "Poll_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "EventPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollOption" ADD CONSTRAINT "PollOption_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_pollOptionId_fkey" FOREIGN KEY ("pollOptionId") REFERENCES "PollOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma.js';
export interface JwtPayload { export interface JwtPayload {
userId: string; userId: string;
@@ -15,7 +16,7 @@ declare global {
} }
} }
export function authenticate(req: Request, res: Response, next: NextFunction): void { export async function authenticate(req: Request, res: Response, next: NextFunction): Promise<void> {
const header = req.headers.authorization; const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) { if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' }); res.status(401).json({ error: 'Missing or invalid Authorization header' });
@@ -25,7 +26,21 @@ export function authenticate(req: Request, res: Response, next: NextFunction): v
const token = header.slice(7); const token = header.slice(7);
try { try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload; const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = payload; const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, role: true, isAdmin: true, isBanned: true },
});
if (!user || user.isBanned) {
res.status(401).json({ error: 'Token user no longer exists or is banned. Please login again.' });
return;
}
req.user = {
userId: user.id,
role: user.role,
isAdmin: user.isAdmin,
};
next(); next();
} catch { } catch {
res.status(401).json({ error: 'Token expired or invalid' }); res.status(401).json({ error: 'Token expired or invalid' });

View File

@@ -217,7 +217,7 @@ export default function ForumPage() {
{!loading && !error && ( {!loading && !error && (
filteredCategories.length === 0 ? ( filteredCategories.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}> <div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
No results found for "{search}" {search.trim() ? `No results found for "${search}"` : 'No forum categories available yet.'}
</div> </div>
) : ( ) : (
filteredCategories.map((cat) => ( filteredCategories.map((cat) => (

View File

@@ -58,39 +58,7 @@ export default function LoginPage() {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}> <form onSubmit={handleSubmit}>
{/* Demo hint */}
<div style={{ background: 'rgba(217,119,6,0.08)', border: '1px solid rgba(217,119,6,0.2)', padding: '0.75rem', marginBottom: '1.5rem', borderRadius: '6px' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.05em', marginBottom: '0.4rem' }}>
[DEMO] Quick login emails:
</div>
{[
{ label: 'Dev/Admin', email: 'kestrel@crowmate.dev' },
{ label: 'Com Staff', email: 'vesper@crowmate.dev' },
{ label: 'User', email: 'glitch@mail.com' },
].map(({ label, email: e }) => (
<button
key={e}
type="button"
onClick={() => { setEmail(e); setPassword('password'); }}
style={{
background: 'transparent',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
display: 'block',
textAlign: 'left',
padding: '0.1rem 0',
width: '100%',
}}
>
&gt; {label}: {e}
</button>
))}
</div>
{errors.form && ( {errors.form && (
<div style={{ background: 'rgba(220,38,38,0.1)', border: '1px solid rgba(220,38,38,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem', borderRadius: '6px' }}> <div style={{ background: 'rgba(220,38,38,0.1)', border: '1px solid rgba(220,38,38,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem', borderRadius: '6px' }}>
[ERROR] {errors.form} [ERROR] {errors.form}

View File

@@ -114,14 +114,26 @@ export const forumApi = {
getCategories: () => getCategories: () =>
apiFetch<ForumCategory[]>('/forum/categories'), apiFetch<ForumCategory[]>('/forum/categories'),
getThreads: (params?: { categoryId?: string; page?: number; limit?: number }) => { getThreads: async (params?: { categoryId?: string; page?: number; limit?: number }) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params?.categoryId) q.set('categoryId', params.categoryId); if (params?.categoryId) q.set('categoryId', params.categoryId);
q.set('page', String(params?.page ?? 1)); q.set('page', String(params?.page ?? 1));
q.set('limit', String(params?.limit ?? 100)); q.set('limit', String(params?.limit ?? 100));
return apiFetch<{ data: ForumThread[]; total: number; page: number; pages: number }>(
`/forum/threads?${q}` const result = await apiFetch<{
); data?: ForumThread[];
threads?: ForumThread[];
total: number;
page: number;
pages: number;
}>(`/forum/threads?${q}`);
return {
data: result.data ?? result.threads ?? [],
total: result.total,
page: result.page,
pages: result.pages,
};
}, },
getThread: (id: string) => getThread: (id: string) =>

View File

@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { formatDate, formatDateTime } from '../../utils/format'; import { formatDate, formatDateTime } from '../../utils/format';
import { settingsApi } from '../../utils/api'; import { bugsApi, settingsApi } from '../../utils/api';
import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types'; import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types';
function StatusBadge({ status }: { status: BugStatus }) { function StatusBadge({ status }: { status: BugStatus }) {
@@ -29,11 +29,39 @@ export default function IntranetBugs() {
const [noteText, setNoteText] = useState(''); const [noteText, setNoteText] = useState('');
const [isEnabled, setIsEnabled] = useState(true); const [isEnabled, setIsEnabled] = useState(true);
const [toggling, setToggling] = useState(false); const [toggling, setToggling] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
useEffect(() => { useEffect(() => {
settingsApi.get().then((s) => setIsEnabled(s.bugsEnabled)).catch(() => {}); settingsApi.get().then((s) => setIsEnabled(s.bugsEnabled)).catch(() => {});
}, []); }, []);
const fetchBugs = useCallback(() => {
setLoading(true);
setLoadError('');
bugsApi
.getBugs({
status: statusFilter,
severity: severityFilter,
assignedTo: assignedFilter,
limit: 100,
})
.then((res) => {
const next = Array.isArray(res?.data) ? res.data : [];
setBugs(next);
setSelected((prev) => (prev ? next.find((b) => b.id === prev.id) ?? null : null));
})
.catch((err) => {
setBugs([]);
setLoadError(err instanceof Error ? err.message : 'Failed to load bug reports.');
})
.finally(() => setLoading(false));
}, [statusFilter, severityFilter, assignedFilter]);
useEffect(() => {
fetchBugs();
}, [fetchBugs]);
const handleToggle = useCallback((enabled: boolean) => { const handleToggle = useCallback((enabled: boolean) => {
setToggling(true); setToggling(true);
settingsApi.update({ bugsEnabled: enabled }) settingsApi.update({ bugsEnabled: enabled })
@@ -59,33 +87,43 @@ export default function IntranetBugs() {
}, [bugs, statusFilter, severityFilter, assignedFilter]); }, [bugs, statusFilter, severityFilter, assignedFilter]);
const updateBug = useCallback((id: string, changes: Partial<BugReport>) => { const updateBug = useCallback((id: string, changes: Partial<BugReport>) => {
setBugs((prev) => prev.map((b) => b.id === id ? { ...b, ...changes, updatedAt: new Date().toISOString() } : b)); setBugs((prev) => prev.map((b) => (b.id === id ? { ...b, ...changes, updatedAt: new Date().toISOString() } : b)));
setSelected((prev) => prev?.id === id ? { ...prev, ...changes, updatedAt: new Date().toISOString() } : prev); setSelected((prev) => (prev?.id === id ? { ...prev, ...changes, updatedAt: new Date().toISOString() } : prev));
}, []); }, []);
const handleAssign = useCallback((bugId: string, staffId: string) => { const handleAssign = useCallback((bugId: string, staffId: string) => {
const staff = STAFF_MEMBERS.find((s) => s.id === staffId); const staff = STAFF_MEMBERS.find((s) => s.id === staffId);
updateBug(bugId, { assignedToId: staffId || undefined, assignedToName: staff?.username }); updateBug(bugId, { assignedToId: staffId || undefined, assignedToName: staff?.username });
}, [updateBug]); bugsApi.updateBug(bugId, { assignedToId: staffId || null }).catch(() => {
fetchBugs();
});
}, [fetchBugs, updateBug]);
const handleStatusChange = useCallback((bugId: string, status: BugStatus) => { const handleStatusChange = useCallback((bugId: string, status: BugStatus) => {
updateBug(bugId, { status }); updateBug(bugId, { status });
}, [updateBug]); bugsApi.updateBug(bugId, { status }).catch(() => {
fetchBugs();
});
}, [fetchBugs, updateBug]);
const handleAddNote = useCallback((bugId: string) => { const handleAddNote = useCallback((bugId: string) => {
if (!noteText.trim() || !user) return; if (!noteText.trim() || !user) return;
const content = noteText.trim();
const note: BugReportNote = { const note: BugReportNote = {
id: `n${Date.now()}`, id: `n${Date.now()}`,
bugReportId: bugId, bugReportId: bugId,
authorId: user.id, authorId: user.id,
authorName: user.username, authorName: user.username,
content: noteText.trim(), content,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
setBugs((prev) => prev.map((b) => b.id === bugId ? { ...b, notes: [...(b.notes ?? []), note] } : b)); setBugs((prev) => prev.map((b) => b.id === bugId ? { ...b, notes: [...(b.notes ?? []), note] } : b));
setSelected((prev) => prev?.id === bugId ? { ...prev, notes: [...(prev.notes ?? []), note] } : prev); setSelected((prev) => prev?.id === bugId ? { ...prev, notes: [...(prev.notes ?? []), note] } : prev);
setNoteText(''); setNoteText('');
}, [noteText, user]); bugsApi.addNote(bugId, content).catch(() => {
fetchBugs();
});
}, [fetchBugs, noteText, user]);
if (!isEnabled) { if (!isEnabled) {
return ( return (
@@ -182,7 +220,16 @@ export default function IntranetBugs() {
{/* Bug list */} {/* Bug list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{filtered.length === 0 ? ( {loadError && (
<div style={{ background: 'rgba(220,38,38,0.08)', border: '1px solid rgba(220,38,38,0.35)', padding: '0.75rem 0.9rem', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem' }}>
{loadError}
</div>
)}
{loading ? (
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
Loading reports...
</div>
) : filtered.length === 0 ? (
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}> <div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
No reports match filters. No reports match filters.
</div> </div>

View File

@@ -1,5 +1,8 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { bugsApi } from '../../utils/api';
import type { BugReport } from '../../types';
interface StatCardProps { interface StatCardProps {
label: string; label: string;
@@ -79,10 +82,30 @@ function NavTile({ to, label, description, icon }: NavTileProps) {
export default function IntranetDashboard() { export default function IntranetDashboard() {
const { user } = useAuth(); const { user } = useAuth();
const [bugs, setBugs] = useState<BugReport[]>([]);
const [loadingBugs, setLoadingBugs] = useState(true);
const [bugError, setBugError] = useState('');
useEffect(() => {
setLoadingBugs(true);
setBugError('');
bugsApi
.getBugs({ limit: 100 })
.then((res) => setBugs(Array.isArray(res?.data) ? res.data : []))
.catch((err) => {
setBugs([]);
setBugError(err instanceof Error ? err.message : 'Failed to load bug reports.');
})
.finally(() => setLoadingBugs(false));
}, []);
const { openBugs, criticalBugs, assignedToMe, recentBugs } = useMemo(() => {
const open = bugs.filter((b) => b.status === 'open').length;
const critical = bugs.filter((b) => b.severity === 'critical').length;
const mine = bugs.filter((b) => b.assignedToId === user?.id).length;
return { openBugs: open, criticalBugs: critical, assignedToMe: mine, recentBugs: bugs.slice(0, 5) };
}, [bugs, user?.id]);
const openBugs = 0;
const criticalBugs = 0;
const assignedToMe = 0;
const totalUsers = 0; const totalUsers = 0;
return ( return (
@@ -105,16 +128,70 @@ export default function IntranetDashboard() {
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}> <div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
QUICK STATS QUICK STATS
</div> </div>
{bugError && (
<div style={{ marginBottom: '0.75rem', background: 'rgba(220,38,38,0.08)', border: '1px solid rgba(220,38,38,0.35)', padding: '0.6rem 0.8rem', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem' }}>
{bugError}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.75rem' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.75rem' }}>
<StatCard label="Open Bugs" value={openBugs} accent="green" /> <StatCard label="Open Bugs" value={loadingBugs ? '...' : openBugs} accent="green" />
<StatCard label="Critical" value={criticalBugs} accent="red" /> <StatCard label="Critical" value={loadingBugs ? '...' : criticalBugs} accent="red" />
<StatCard label="Assigned to Me" value={assignedToMe} accent="amber" /> <StatCard label="Assigned to Me" value={loadingBugs ? '...' : assignedToMe} accent="amber" />
<StatCard label="Total Users" value={totalUsers} accent="green" /> <StatCard label="Total Users" value={totalUsers} accent="green" />
<StatCard label="Forum Threads" value={0} accent="green" /> <StatCard label="Forum Threads" value={0} accent="green" />
<StatCard label="Staff Posts Today" value={0} accent="amber" /> <StatCard label="Staff Posts Today" value={0} accent="amber" />
</div> </div>
</div> </div>
{/* Recent bug reports */}
<div style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em' }}>
RECENT BUG REPORTS
</div>
<Link to="/intranet/bugs" style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-yellow)', fontSize: '0.72rem', textDecoration: 'none' }}>
View all
</Link>
</div>
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
{loadingBugs ? (
<div style={{ padding: '1rem', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
Loading bug reports...
</div>
) : recentBugs.length === 0 ? (
<div style={{ padding: '1rem', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
No bug reports yet.
</div>
) : (
recentBugs.map((bug) => (
<Link
key={bug.id}
to="/intranet/bugs"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
padding: '0.75rem 1rem',
textDecoration: 'none',
borderTop: '1px solid var(--color-border)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.66rem' }}>{bug.uniqueCode}</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.78rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{bug.title}
</div>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', flexShrink: 0 }}>
{bug.status}
</div>
</Link>
))
)}
</div>
</div>
{/* Navigation tiles */} {/* Navigation tiles */}
<div style={{ marginBottom: '2rem' }}> <div style={{ marginBottom: '2rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}> <div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>

View File

@@ -1,21 +1,80 @@
import { useState, useMemo, useCallback, useEffect } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import { formatDateTime } from '../../utils/format'; import { formatDateTime } from '../../utils/format';
import { settingsApi } from '../../utils/api'; import { forumApi, settingsApi } from '../../utils/api';
import type { ForumThread, ForumReply } from '../../types'; import type { ForumCategory, ForumReply, ForumThread } from '../../types';
export default function IntranetModeration() { export default function IntranetModeration() {
const [categories, setCategories] = useState<ForumCategory[]>([]);
const [threads, setThreads] = useState<ForumThread[]>([]); const [threads, setThreads] = useState<ForumThread[]>([]);
const [replies, setReplies] = useState<ForumReply[]>([]); const [replies, setReplies] = useState<ForumReply[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null); const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [createTitle, setCreateTitle] = useState('');
const [createContent, setCreateContent] = useState('');
const [createCategoryId, setCreateCategoryId] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
const [categoryName, setCategoryName] = useState('');
const [categoryDescription, setCategoryDescription] = useState('');
const [categoryIcon, setCategoryIcon] = useState('📁');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads'); const [activeTab, setActiveTab] = useState<'threads' | 'replies' | 'categories'>('threads');
const [isEnabled, setIsEnabled] = useState(true); const [isEnabled, setIsEnabled] = useState(true);
const [toggling, setToggling] = useState(false); const [toggling, setToggling] = useState(false);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [savingCategory, setSavingCategory] = useState(false);
const [error, setError] = useState('');
const loadModerationData = useCallback(async () => {
setLoading(true);
setError('');
try {
const [cats, threadRes] = await Promise.all([
forumApi.getCategories(),
forumApi.getThreads({ limit: 200 }),
]);
const loadedThreads = threadRes.data;
setCategories(cats);
setThreads(loadedThreads);
const detailed = await Promise.all(
loadedThreads.map((thread) => forumApi.getThread(thread.id).catch(() => null))
);
const allReplies = detailed
.filter((thread): thread is ForumThread & { replies: ForumReply[] } => Boolean(thread))
.flatMap((thread) => thread.replies);
setReplies(allReplies);
} catch {
setError('Failed to load moderation data.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { useEffect(() => {
settingsApi.get().then((s) => setIsEnabled(s.forumEnabled)).catch(() => {}); settingsApi.get().then((s) => setIsEnabled(s.forumEnabled)).catch(() => {});
}, []); }, []);
useEffect(() => {
void loadModerationData();
}, [loadModerationData]);
useEffect(() => {
if (categories.length === 0) {
setCreateCategoryId('');
return;
}
const exists = categories.some((category) => category.id === createCategoryId);
if (!exists) {
setCreateCategoryId(categories[0].id);
}
}, [categories, createCategoryId]);
const handleToggle = useCallback((enabled: boolean) => { const handleToggle = useCallback((enabled: boolean) => {
setToggling(true); setToggling(true);
settingsApi.update({ forumEnabled: enabled }) settingsApi.update({ forumEnabled: enabled })
@@ -36,23 +95,151 @@ export default function IntranetModeration() {
}, [replies, selectedThreadId]); }, [replies, selectedThreadId]);
const deleteThread = useCallback((id: string) => { const deleteThread = useCallback((id: string) => {
setThreads((prev) => prev.filter((t) => t.id !== id)); forumApi.deleteThread(id)
setReplies((prev) => prev.filter((r) => r.threadId !== id)); .then(() => {
if (selectedThreadId === id) setSelectedThreadId(null); setThreads((prev) => prev.filter((t) => t.id !== id));
setReplies((prev) => prev.filter((r) => r.threadId !== id));
if (selectedThreadId === id) setSelectedThreadId(null);
})
.catch(() => {
setError('Failed to delete thread.');
});
}, [selectedThreadId]); }, [selectedThreadId]);
const togglePin = useCallback((id: string) => { const togglePin = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t)); const thread = threads.find((t) => t.id === id);
}, []); if (!thread) return;
forumApi.updateThread(id, { isPinned: !thread.isPinned })
.then((updated) => {
setThreads((prev) => prev.map((t) => (t.id === id ? updated : t)));
})
.catch(() => {
setError('Failed to update pin state.');
});
}, [threads]);
const toggleLock = useCallback((id: string) => { const toggleLock = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t)); const thread = threads.find((t) => t.id === id);
}, []); if (!thread) return;
forumApi.updateThread(id, { isLocked: !thread.isLocked })
.then((updated) => {
setThreads((prev) => prev.map((t) => (t.id === id ? updated : t)));
})
.catch(() => {
setError('Failed to update lock state.');
});
}, [threads]);
const deleteReply = useCallback((id: string) => { const deleteReply = useCallback((id: string) => {
setReplies((prev) => prev.filter((r) => r.id !== id)); const removedReply = replies.find((r) => r.id === id);
forumApi.deleteReply(id)
.then(() => {
setReplies((prev) => prev.filter((r) => r.id !== id));
setThreads((prev) => prev.map((t) => {
if (!removedReply || removedReply.threadId !== t.id) return t;
return { ...t, replyCount: Math.max(0, t.replyCount - 1) };
}));
})
.catch(() => {
setError('Failed to delete reply.');
});
}, [replies]);
const createThread = useCallback(() => {
const title = createTitle.trim();
const content = createContent.trim();
if (!title || !content || !createCategoryId) {
setError('Title, category and content are required.');
return;
}
setCreating(true);
setError('');
forumApi.createThread({
title,
content,
categoryId: createCategoryId,
})
.then((thread) => {
setThreads((prev) => [thread, ...prev]);
setCategories((prev) => prev.map((cat) => (
cat.id === createCategoryId
? { ...cat, threadCount: cat.threadCount + 1 }
: cat
)));
setCreateTitle('');
setCreateContent('');
setIsCreateModalOpen(false);
})
.catch(() => {
setError('Failed to create thread.');
})
.finally(() => {
setCreating(false);
});
}, [createCategoryId, createContent, createTitle]);
const openCreateCategoryModal = useCallback(() => {
setEditingCategoryId(null);
setCategoryName('');
setCategoryDescription('');
setCategoryIcon('📁');
setIsCategoryModalOpen(true);
}, []); }, []);
const openEditCategoryModal = useCallback((category: ForumCategory) => {
setEditingCategoryId(category.id);
setCategoryName(category.name);
setCategoryDescription(category.description);
setCategoryIcon(category.icon || '📁');
setIsCategoryModalOpen(true);
}, []);
const saveCategory = useCallback(() => {
const name = categoryName.trim();
const description = categoryDescription.trim();
const icon = categoryIcon.trim() || '📁';
if (!name || !description) {
setError('Category name and description are required.');
return;
}
setSavingCategory(true);
setError('');
const action = editingCategoryId
? forumApi.updateCategory(editingCategoryId, { name, description, icon })
: forumApi.createCategory({ name, description, icon });
action
.then(() => loadModerationData())
.then(() => setIsCategoryModalOpen(false))
.catch(() => {
setError(editingCategoryId ? 'Failed to update category.' : 'Failed to create category.');
})
.finally(() => {
setSavingCategory(false);
});
}, [categoryDescription, categoryIcon, categoryName, editingCategoryId, loadModerationData]);
const removeCategory = useCallback((id: string) => {
const confirmed = window.confirm('Delete this category? This can fail if it still has threads.');
if (!confirmed) return;
setError('');
forumApi.deleteCategory(id)
.then(() => loadModerationData())
.catch(() => {
setError('Failed to delete category. Remove or move threads first.');
});
}, [loadModerationData]);
const recentReplies = useMemo(() => { const recentReplies = useMemo(() => {
return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20); return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20);
}, [replies]); }, [replies]);
@@ -87,6 +274,16 @@ export default function IntranetModeration() {
</div> </div>
) : ( ) : (
<div> <div>
{loading && (
<div style={{ marginBottom: '1rem', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
Loading moderation data...
</div>
)}
{error && (
<div style={{ marginBottom: '1rem', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
{error}
</div>
)}
<div style={{ marginBottom: '1.75rem' }}> <div style={{ marginBottom: '1.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div> <div>
@@ -123,7 +320,7 @@ export default function IntranetModeration() {
{/* Tabs */} {/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: 0 }}> <div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: 0 }}>
{(['threads', 'replies'] as const).map((tab) => ( {(['threads', 'replies', 'categories'] as const).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
@@ -140,7 +337,11 @@ export default function IntranetModeration() {
letterSpacing: '0.08em', letterSpacing: '0.08em',
}} }}
> >
{tab === 'threads' ? `Threads (${threads.length})` : `Replies (${replies.length})`} {tab === 'threads'
? `Threads (${threads.length})`
: tab === 'replies'
? `Replies (${replies.length})`
: `Categories (${categories.length})`}
</button> </button>
))} ))}
</div> </div>
@@ -149,6 +350,22 @@ export default function IntranetModeration() {
<div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}>
{/* Thread list */} {/* Thread list */}
<div> <div>
<div style={{ marginBottom: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
className="btn-terminal btn-amber"
onClick={() => setIsCreateModalOpen(true)}
disabled={loading || categories.length === 0}
style={{ opacity: loading || categories.length === 0 ? 0.6 : 1 }}
>
+ Create Thread
</button>
{categories.length === 0 && (
<div style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem' }}>
No categories available.
</div>
)}
</div>
<input <input
className="input-terminal" className="input-terminal"
type="search" type="search"
@@ -192,6 +409,7 @@ export default function IntranetModeration() {
className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`} className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`}
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => togglePin(thread.id)} onClick={() => togglePin(thread.id)}
disabled={loading}
> >
{thread.isPinned ? 'Unpin' : 'Pin'} {thread.isPinned ? 'Unpin' : 'Pin'}
</button> </button>
@@ -199,6 +417,7 @@ export default function IntranetModeration() {
className="btn-terminal btn-amber" className="btn-terminal btn-amber"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => toggleLock(thread.id)} onClick={() => toggleLock(thread.id)}
disabled={loading}
> >
{thread.isLocked ? 'Unlock' : 'Lock'} {thread.isLocked ? 'Unlock' : 'Lock'}
</button> </button>
@@ -206,6 +425,7 @@ export default function IntranetModeration() {
className="btn-terminal btn-danger" className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => deleteThread(thread.id)} onClick={() => deleteThread(thread.id)}
disabled={loading}
> >
Delete Delete
</button> </button>
@@ -292,6 +512,203 @@ export default function IntranetModeration() {
)} )}
</div> </div>
)} )}
{activeTab === 'categories' && (
<div>
<div style={{ marginBottom: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn-terminal btn-amber" onClick={openCreateCategoryModal}>
+ Create Category
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '0.75rem' }}>
{categories.map((category) => (
<div
key={category.id}
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '1rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.4rem', gap: '0.5rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.9rem' }}>
{category.icon} {category.name}
</div>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
{category.threadCount} threads
</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', lineHeight: 1.6 }}>
{category.description}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.4rem', marginTop: '0.7rem' }}>
<button
className="btn-terminal"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => openEditCategoryModal(category)}
>
Modify
</button>
<button
className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => removeCategory(category.id)}
>
Remove
</button>
</div>
</div>
))}
{categories.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
No categories found.
</div>
)}
</div>
</div>
)}
{isCreateModalOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 40,
padding: '1rem',
}}
>
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', width: 'min(620px, 100%)', padding: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.8rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>
CREATE THREAD
</div>
<button
onClick={() => setIsCreateModalOpen(false)}
style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }}
aria-label="Close create thread popup"
>
&#x2715;
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.6rem' }}>
<input
className="input-terminal"
type="text"
placeholder="Thread title"
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
/>
<select
className="input-terminal"
value={createCategoryId}
onChange={(e) => setCreateCategoryId(e.target.value)}
>
{categories.map((category) => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</select>
<textarea
className="input-terminal"
rows={4}
placeholder="Thread content"
value={createContent}
onChange={(e) => setCreateContent(e.target.value)}
style={{ resize: 'vertical' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<button className="btn-terminal" onClick={() => setIsCreateModalOpen(false)}>
Cancel
</button>
<button
className="btn-terminal btn-amber"
onClick={createThread}
disabled={creating || loading || categories.length === 0}
style={{ opacity: creating || loading || categories.length === 0 ? 0.6 : 1 }}
>
{creating ? 'Creating...' : 'Create Thread'}
</button>
</div>
</div>
</div>
</div>
)}
{isCategoryModalOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
padding: '1rem',
}}
>
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', width: 'min(560px, 100%)', padding: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.8rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>
{editingCategoryId ? 'MODIFY CATEGORY' : 'CREATE CATEGORY'}
</div>
<button
onClick={() => setIsCategoryModalOpen(false)}
style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }}
aria-label="Close category popup"
>
&#x2715;
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.6rem' }}>
<input
className="input-terminal"
type="text"
placeholder="Category name"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
/>
<input
className="input-terminal"
type="text"
placeholder="Icon (emoji)"
value={categoryIcon}
onChange={(e) => setCategoryIcon(e.target.value)}
/>
<textarea
className="input-terminal"
rows={3}
placeholder="Category description"
value={categoryDescription}
onChange={(e) => setCategoryDescription(e.target.value)}
style={{ resize: 'vertical' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<button className="btn-terminal" onClick={() => setIsCategoryModalOpen(false)}>
Cancel
</button>
<button
className="btn-terminal btn-amber"
onClick={saveCategory}
disabled={savingCategory}
style={{ opacity: savingCategory ? 0.6 : 1 }}
>
{savingCategory ? 'Saving...' : editingCategoryId ? 'Save Changes' : 'Create Category'}
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,13 @@
import { getToken } from '../contexts/AuthContext'; import { getToken } from '../contexts/AuthContext';
import type {
BugReport,
BugReportNote,
BugSeverity,
BugStatus,
ForumCategory,
ForumReply,
ForumThread,
} from '../types';
export const API_BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? '/api'; export const API_BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? '/api';
@@ -22,6 +31,84 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
type ThreadsResponse = {
data?: ForumThread[];
threads?: ForumThread[];
total: number;
page: number;
pages: number;
};
export const forumApi = {
getCategories: () => apiFetch<ForumCategory[]>('/forum/categories'),
createCategory: (payload: { name: string; description: string; icon: string }) =>
apiFetch<ForumCategory>('/forum/categories', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateCategory: (id: string, payload: { name?: string; description?: string; icon?: string }) =>
apiFetch<ForumCategory>(`/forum/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteCategory: (id: string) =>
apiFetch<void>(`/forum/categories/${id}`, {
method: 'DELETE',
}),
getThreads: async (params?: { categoryId?: string; page?: number; limit?: number }) => {
const q = new URLSearchParams();
if (params?.categoryId) q.set('categoryId', params.categoryId);
q.set('page', String(params?.page ?? 1));
q.set('limit', String(params?.limit ?? 100));
const result = await apiFetch<ThreadsResponse>(`/forum/threads?${q.toString()}`);
return {
data: result.data ?? result.threads ?? [],
total: result.total,
page: result.page,
pages: result.pages,
};
},
getThread: (id: string) => apiFetch<ForumThread & { replies: ForumReply[] }>(`/forum/threads/${id}`),
createThread: (payload: { title: string; content: string; categoryId: string }) =>
apiFetch<ForumThread>('/forum/threads', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateThread: (id: string, payload: { isPinned?: boolean; isLocked?: boolean; title?: string; content?: string }) =>
apiFetch<ForumThread>(`/forum/threads/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteThread: (id: string) =>
apiFetch<void>(`/forum/threads/${id}`, {
method: 'DELETE',
}),
deleteReply: (id: string) =>
apiFetch<void>(`/forum/replies/${id}`, {
method: 'DELETE',
}),
};
export type SiteSettings = { forumEnabled: boolean; bugsEnabled: boolean }; export type SiteSettings = { forumEnabled: boolean; bugsEnabled: boolean };
export const settingsApi = { export const settingsApi = {
@@ -33,3 +120,34 @@ export const settingsApi = {
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
}; };
export const bugsApi = {
getBugs: (params?: {
status?: BugStatus | 'all';
severity?: BugSeverity | 'all';
assignedTo?: string | 'all';
page?: number;
limit?: number;
}) => {
const q = new URLSearchParams();
if (params?.status && params.status !== 'all') q.set('status', params.status);
if (params?.severity && params.severity !== 'all') q.set('severity', params.severity);
if (params?.assignedTo && params.assignedTo !== 'all') q.set('assignedTo', params.assignedTo);
q.set('page', String(params?.page ?? 1));
q.set('limit', String(params?.limit ?? 100));
return apiFetch<{ data: BugReport[]; total: number; page: number; pages: number }>(`/bugs?${q.toString()}`);
},
updateBug: (id: string, data: { status?: BugStatus; assignedToId?: string | null }) =>
apiFetch<BugReport>(`/bugs/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
addNote: (id: string, content: string) =>
apiFetch<BugReportNote>(`/bugs/${id}/notes`, {
method: 'POST',
body: JSON.stringify({ content }),
}),
};

View File

@@ -0,0 +1,897 @@
{
"info": {
"name": "CrowMate API",
"_postman_id": "crowmate-nest-api",
"description": "CrowMate Nest backend API — all endpoints organized by resource. After logging in, the token is automatically saved to the `token` collection variable.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "token",
"value": "",
"type": "string"
}
],
"item": [
{
"name": "Health",
"item": [
{
"name": "Health Check",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/health",
"host": ["{{baseUrl}}"],
"path": ["api", "health"]
}
}
}
]
},
{
"name": "Auth",
"item": [
{
"name": "Login",
"event": [
{
"listen": "test",
"script": {
"exec": [
"const res = pm.response.json();",
"if (res.token) {",
" pm.collectionVariables.set('token', res.token);",
" pm.test('Token saved', () => pm.expect(res.token).to.be.a('string'));",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"{{adminEmail}}\",\n \"password\": \"{{adminPassword}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/auth/login",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "login"]
}
}
},
{
"name": "Register",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"testuser\",\n \"email\": \"testuser@example.com\",\n \"password\": \"password123\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/auth/register",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "register"]
}
}
},
{
"name": "Me",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/auth/me",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "me"]
}
}
}
]
},
{
"name": "Users",
"item": [
{
"name": "List Users (Admin)",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users",
"host": ["{{baseUrl}}"],
"path": ["api", "users"]
}
}
},
{
"name": "Create Staff Account (Admin)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"staffmember\",\n \"email\": \"staff@example.com\",\n \"password\": \"password123\",\n \"role\": \"dev\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users",
"host": ["{{baseUrl}}"],
"path": ["api", "users"]
}
}
},
{
"name": "My Profile",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users/me/profile",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "me", "profile"]
}
}
},
{
"name": "Update My Username",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"newusername\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users/me/username",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "me", "username"]
}
}
},
{
"name": "Update My Password",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"currentPassword\": \"oldpassword\",\n \"newPassword\": \"newpassword123\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users/me/password",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "me", "password"]
}
}
},
{
"name": "Get User by ID (Admin)",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "users", ":id"],
"variable": [{ "key": "id", "value": "USER_ID_HERE" }]
}
}
},
{
"name": "Update User Role (Admin)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"role\": \"dev\",\n \"isAdmin\": false\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "users", ":id"],
"variable": [{ "key": "id", "value": "USER_ID_HERE" }]
}
}
},
{
"name": "Ban User (Admin)",
"request": {
"method": "POST",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users/:id/ban",
"host": ["{{baseUrl}}"],
"path": ["api", "users", ":id", "ban"],
"variable": [{ "key": "id", "value": "USER_ID_HERE" }]
}
}
},
{
"name": "Unban User (Admin)",
"request": {
"method": "POST",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users/:id/unban",
"host": ["{{baseUrl}}"],
"path": ["api", "users", ":id", "unban"],
"variable": [{ "key": "id", "value": "USER_ID_HERE" }]
}
}
}
]
},
{
"name": "Forum",
"item": [
{
"name": "Categories",
"item": [
{
"name": "List Categories",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/forum/categories",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "categories"]
}
}
},
{
"name": "Create Category (Admin)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"General\",\n \"description\": \"General discussion\",\n \"icon\": \"💬\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/categories",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "categories"]
}
}
},
{
"name": "Update Category (Admin)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"General Updated\",\n \"description\": \"Updated description\",\n \"icon\": \"🗨️\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/categories/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "categories", ":id"],
"variable": [{ "key": "id", "value": "CATEGORY_ID_HERE" }]
}
}
},
{
"name": "Delete Category (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/forum/categories/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "categories", ":id"],
"variable": [{ "key": "id", "value": "CATEGORY_ID_HERE" }]
}
}
}
]
},
{
"name": "Threads",
"item": [
{
"name": "List Threads",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/forum/threads?categoryId=&page=1&limit=20",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads"],
"query": [
{ "key": "categoryId", "value": "", "description": "Filter by category ID" },
{ "key": "page", "value": "1" },
{ "key": "limit", "value": "20", "description": "Max 50" }
]
}
}
},
{
"name": "Get Thread",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/forum/threads/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads", ":id"],
"variable": [{ "key": "id", "value": "THREAD_ID_HERE" }]
}
}
},
{
"name": "Create Thread",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"My thread title\",\n \"content\": \"Thread content here\",\n \"categoryId\": \"CATEGORY_ID_HERE\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/threads",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads"]
}
}
},
{
"name": "Update Thread",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated title\",\n \"content\": \"Updated content\",\n \"isPinned\": false,\n \"isLocked\": false\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/threads/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads", ":id"],
"variable": [{ "key": "id", "value": "THREAD_ID_HERE" }]
}
}
},
{
"name": "Delete Thread (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/forum/threads/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads", ":id"],
"variable": [{ "key": "id", "value": "THREAD_ID_HERE" }]
}
}
}
]
},
{
"name": "Replies",
"item": [
{
"name": "Post Reply",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"My reply content\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/threads/:id/replies",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads", ":id", "replies"],
"variable": [{ "key": "id", "value": "THREAD_ID_HERE" }]
}
}
},
{
"name": "Delete Reply (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/forum/replies/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "replies", ":id"],
"variable": [{ "key": "id", "value": "REPLY_ID_HERE" }]
}
}
}
]
}
]
},
{
"name": "Bugs",
"item": [
{
"name": "List Bugs",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/bugs?status=all&severity=all&page=1&limit=20",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs"],
"query": [
{ "key": "status", "value": "all", "description": "open | in_progress | resolved | closed | all" },
{ "key": "severity", "value": "all", "description": "low | medium | high | critical | all" },
{ "key": "assignedTo", "value": "", "description": "userId | unassigned | all", "disabled": true },
{ "key": "page", "value": "1" },
{ "key": "limit", "value": "20", "description": "Max 50" }
]
}
}
},
{
"name": "Get Bug",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/bugs/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Submit Bug Report",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Bug title\",\n \"description\": \"Detailed description of the bug\",\n \"stepsToReproduce\": \"1. Do this\\n2. Do that\\n3. See error\",\n \"severity\": \"medium\",\n \"gameVersion\": \"1.0.0\",\n \"screenshotUrl\": \"\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/bugs",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs"]
}
}
},
{
"name": "Update Bug (Staff)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"status\": \"in_progress\",\n \"assignedToId\": null,\n \"severity\": \"high\",\n \"title\": \"Updated bug title\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/bugs/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Toggle Me Too",
"request": {
"method": "POST",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/bugs/:id/me-too",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id", "me-too"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Add Comment",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"This is a public comment on the bug\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/bugs/:id/comments",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id", "comments"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Delete Comment (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/bugs/comments/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", "comments", ":id"],
"variable": [{ "key": "id", "value": "COMMENT_ID_HERE" }]
}
}
},
{
"name": "Add Staff Note (Staff)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"Internal staff note — not visible to users\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/bugs/:id/notes",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id", "notes"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Delete Staff Note (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/bugs/notes/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", "notes", ":id"],
"variable": [{ "key": "id", "value": "NOTE_ID_HERE" }]
}
}
}
]
},
{
"name": "Events",
"item": [
{
"name": "List Events",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/events?public=true&page=1&limit=20",
"host": ["{{baseUrl}}"],
"path": ["api", "events"],
"query": [
{ "key": "public", "value": "true", "description": "Filter to public events only" },
{ "key": "page", "value": "1" },
{ "key": "limit", "value": "20" }
]
}
}
},
{
"name": "Get Event",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/events/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
},
{
"name": "Create Announcement (Staff)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"announcement\",\n \"title\": \"Big announcement\",\n \"content\": \"Something important happened!\",\n \"isPublic\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events",
"host": ["{{baseUrl}}"],
"path": ["api", "events"]
}
}
},
{
"name": "Create Poll (Staff)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"poll\",\n \"title\": \"Community Poll\",\n \"content\": \"Vote on your favourite feature!\",\n \"isPublic\": true,\n \"poll\": {\n \"question\": \"What should we add next?\",\n \"options\": [\n { \"text\": \"New maps\" },\n { \"text\": \"More characters\" },\n { \"text\": \"Better UI\" }\n ],\n \"isActive\": true,\n \"allowMultipleVotes\": false\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events",
"host": ["{{baseUrl}}"],
"path": ["api", "events"]
}
}
},
{
"name": "Update Event (Staff)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated title\",\n \"content\": \"Updated content\",\n \"isPublic\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
},
{
"name": "Delete Event (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/events/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
},
{
"name": "Vote on Poll",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"optionIds\": [\"OPTION_ID_HERE\"]\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events/:id/vote",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id", "vote"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
},
{
"name": "Control Poll (Staff)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"isActive\": false,\n \"endsAt\": null\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events/:id/poll",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id", "poll"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
}
]
},
{
"name": "Feed",
"item": [
{
"name": "List Feed (Staff)",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/feed",
"host": ["{{baseUrl}}"],
"path": ["api", "feed"]
}
}
},
{
"name": "Post to Feed (Staff)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"Internal staff feed post content\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/feed",
"host": ["{{baseUrl}}"],
"path": ["api", "feed"]
}
}
},
{
"name": "Delete Feed Post",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/feed/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "feed", ":id"],
"variable": [{ "key": "id", "value": "POST_ID_HERE" }]
}
}
}
]
},
{
"name": "Team",
"item": [
{
"name": "List Team Members",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/team",
"host": ["{{baseUrl}}"],
"path": ["api", "team"]
}
}
},
{
"name": "Create Team Member (Admin)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"John Doe\",\n \"role\": \"Lead Developer\",\n \"bio\": \"A short bio about this team member.\",\n \"avatarInitials\": \"JD\",\n \"social\": {\n \"twitter\": \"johndoe\",\n \"github\": \"johndoe\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/team",
"host": ["{{baseUrl}}"],
"path": ["api", "team"]
}
}
},
{
"name": "Update Team Member (Admin)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"John Doe\",\n \"role\": \"Senior Developer\",\n \"bio\": \"Updated bio.\",\n \"avatarInitials\": \"JD\",\n \"social\": {\n \"twitter\": \"johndoe\",\n \"github\": \"johndoe\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/team/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "team", ":id"],
"variable": [{ "key": "id", "value": "MEMBER_ID_HERE" }]
}
}
},
{
"name": "Delete Team Member (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/team/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "team", ":id"],
"variable": [{ "key": "id", "value": "MEMBER_ID_HERE" }]
}
}
}
]
},
{
"name": "Settings",
"item": [
{
"name": "Get Settings",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/settings",
"host": ["{{baseUrl}}"],
"path": ["api", "settings"]
}
}
},
{
"name": "Update Settings (Admin)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"forumEnabled\": true,\n \"bugsEnabled\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/settings",
"host": ["{{baseUrl}}"],
"path": ["api", "settings"]
}
}
}
]
}
]
}

View File

@@ -0,0 +1,34 @@
{
"id": "crowmate-local-env",
"name": "CrowMate — Local",
"values": [
{
"key": "baseUrl",
"value": "http://localhost:3000",
"type": "default",
"enabled": true
},
{
"key": "adminEmail",
"value": "admin@example.com",
"type": "default",
"enabled": true
},
{
"key": "adminPassword",
"value": "change_me",
"type": "secret",
"enabled": true
},
{
"key": "token",
"value": "",
"type": "secret",
"enabled": true,
"description": "Auto-populated by the Login request"
}
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2026-03-26T00:00:00.000Z",
"_postman_exported_using": "Claude Code"
}

View File

@@ -0,0 +1,37 @@
{
"id": "crowmate-prod-env",
"name": "CrowMate — Production",
"values": [
{
"key": "baseUrl",
"value": "https://api.your-domain.com",
"type": "default",
"enabled": true,
"description": "Replace with your actual production API URL"
},
{
"key": "adminEmail",
"value": "admin@your-domain.com",
"type": "default",
"enabled": true,
"description": "Replace with your production admin email"
},
{
"key": "adminPassword",
"value": "",
"type": "secret",
"enabled": true,
"description": "Fill in before use — never commit this value"
},
{
"key": "token",
"value": "",
"type": "secret",
"enabled": true,
"description": "Auto-populated by the Login request"
}
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2026-03-26T00:00:00.000Z",
"_postman_exported_using": "Claude Code"
}