commit 7958510989080a288786f33e461330ee3fe2e846 Author: default Date: Sat Feb 28 04:21:27 2026 +0000 ALL 0.1.0 Code diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..555e617 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.svelte-kit +build +data +server/bin +.git +*.md +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f431d49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +bun.lock +data/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bc31e15 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.css": "tailwindcss" + } +} diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..1930af1 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,471 @@ +# FPMB — Build & Architecture Reference + +This document is the authoritative reference for agentic coding sessions. Read it fully before making changes. + +--- + +## Quick-start commands + +```bash +# Frontend dev server +bun run dev + +# Frontend production build (source of truth for TS errors) +bun run build + +# Backend run (from server/) +go run ./cmd/api/main.go + +# Backend compile check (source of truth for Go errors) +go build ./... +``` + +- Go binary: `/usr/bin/go` (1.22.2) +- Package manager: `bun` (not npm/pnpm/yarn) +- Go module path: `github.com/fpmb/server` +- Backend must be run from `server/` — relative paths like `../data/` resolve from there + +--- + +## Repository layout + +``` +openboard/ +├── src/ SvelteKit frontend (static SPA adapter) +│ ├── lib/ +│ │ ├── api/ +│ │ │ ├── client.ts apiFetch, apiFetchFormData, token management +│ │ │ └── index.ts all typed API methods grouped by resource +│ │ ├── components/ +│ │ │ ├── Markdown/ Markdown.svelte — renders marked + DOMPurify +│ │ │ └── Modal/ Modal.svelte — generic overlay +│ │ ├── stores/ +│ │ │ └── auth.svelte.ts authStore singleton (init, login, register, logout, setUser) +│ │ ├── types/ +│ │ │ └── api.ts TypeScript interfaces mirroring Go models +│ │ └── utils/ +│ │ └── fileRefs.ts resolveFileRefs() — converts $file: refs in Markdown +│ └── routes/ +│ ├── (auth)/ login, register pages (no auth guard) +│ └── (app)/ +│ ├── +layout.svelte auth guard, top navbar (avatar, logout button) +│ ├── +page.svelte dashboard +│ ├── board/[id]/ Kanban board +│ ├── calendar/ month/week calendar +│ ├── notifications/ notification inbox +│ ├── projects/ project list + project settings +│ ├── settings/user/ user profile + avatar upload + password change +│ ├── team/[id]/ team overview + team settings (avatar/banner upload) +│ └── whiteboard/[id]/ canvas whiteboard +├── server/ +│ ├── cmd/api/main.go Fiber app bootstrap, all route registrations +│ └── internal/ +│ ├── database/db.go MongoDB connection, GetCollection helper +│ ├── handlers/ one file per resource group (auth, teams, projects, …) +│ ├── middleware/auth.go JWT Protected() middleware +│ ├── models/models.go all MongoDB document structs (source of truth for field names) +│ ├── routes/ (unused legacy dir — ignore) +│ └── utils/ shared Go utilities +├── static/ fonts, favicon +├── data/ runtime file storage (gitignored) +│ ├── teams//avatar. +│ ├── teams//banner. +│ ├── users//avatar. +│ └── projects//files/… +├── build/ SvelteKit production output (served by Go) +├── package.json +├── svelte.config.js +├── vite.config.ts +└── tsconfig.json +``` + +--- + +## Backend (Go + GoFiber v2) + +### Route registration — `server/cmd/api/main.go` + +All routes are registered here. Add new routes to the appropriate group. Every group except `/auth` is wrapped in `middleware.Protected()`. + +``` +/api/health GET — liveness check +/api/auth/register POST +/api/auth/login POST +/api/auth/refresh POST +/api/auth/logout POST (Protected) + +/api/users/me GET PUT +/api/users/me/password PUT +/api/users/me/avatar POST GET (multipart upload / serve) +/api/users/me/files GET +/api/users/me/files/folder POST +/api/users/me/files/upload POST +/api/users/search GET ?q= + +/api/teams GET POST +/api/teams/:teamId GET PUT DELETE +/api/teams/:teamId/members GET +/api/teams/:teamId/members/invite POST +/api/teams/:teamId/members/:userId PUT DELETE +/api/teams/:teamId/projects GET POST +/api/teams/:teamId/events GET POST +/api/teams/:teamId/docs GET POST +/api/teams/:teamId/files GET +/api/teams/:teamId/files/folder POST +/api/teams/:teamId/files/upload POST +/api/teams/:teamId/avatar POST GET +/api/teams/:teamId/banner POST GET + +/api/projects GET POST +/api/projects/:projectId GET PUT DELETE +/api/projects/:projectId/archive PUT +/api/projects/:projectId/members GET POST +/api/projects/:projectId/members/:userId PUT DELETE +/api/projects/:projectId/board GET +/api/projects/:projectId/columns POST +/api/projects/:projectId/columns/:columnId PUT DELETE +/api/projects/:projectId/columns/:columnId/position PUT +/api/projects/:projectId/columns/:columnId/cards POST +/api/projects/:projectId/events GET POST +/api/projects/:projectId/files GET +/api/projects/:projectId/files/folder POST +/api/projects/:projectId/files/upload POST +/api/projects/:projectId/webhooks GET POST +/api/projects/:projectId/whiteboard GET PUT + +/api/cards/:cardId PUT DELETE +/api/cards/:cardId/move PUT + +/api/events/:eventId PUT DELETE + +/api/notifications GET +/api/notifications/read-all PUT +/api/notifications/:notifId/read PUT +/api/notifications/:notifId DELETE + +/api/docs/:docId GET PUT DELETE + +/api/files/:fileId/download GET +/api/files/:fileId DELETE + +/api/webhooks/:webhookId PUT DELETE +/api/webhooks/:webhookId/toggle PUT +``` + +### MongoDB models — `server/internal/models/models.go` + +This file is the single source of truth for all field names and types. Always check here before referencing a field. + +| Struct | Collection | Key fields | +|---|---|---| +| `User` | `users` | `_id`, `name`, `email`, `password_hash` (json:`-`), `avatar_url` | +| `Team` | `teams` | `_id`, `name`, `workspace_id`, `avatar_url`, `banner_url`, `created_by` | +| `TeamMember` | `team_members` | `team_id`, `user_id`, `role_flags`, `invited_by` | +| `Project` | `projects` | `_id`, `team_id`, `name`, `description`, `visibility`, `is_public`, `is_archived`, `created_by` | +| `ProjectMember` | `project_members` | `project_id`, `user_id`, `role_flags` | +| `BoardColumn` | `columns` | `project_id`, `title`, `position` | +| `Card` | `cards` | `column_id`, `project_id`, `title`, `description`, `priority`, `color`, `due_date`, `assignees []string`, `subtasks []Subtask`, `position` | +| `Subtask` | (embedded) | `id int`, `text`, `done` | +| `Event` | `events` | `title`, `date`, `time`, `color`, `description`, `scope`, `scope_id` | +| `Notification` | `notifications` | `user_id`, `type`, `message`, `project_id`, `card_id`, `read` | +| `Doc` | `docs` | `team_id`, `title`, `content`, `created_by` | +| `File` | `files` | `project_id`, `team_id`, `user_id`, `name`, `type`, `size_bytes`, `parent_id`, `storage_url` | +| `Webhook` | `webhooks` | `project_id`, `name`, `type`, `url`, `secret_hash` (json:`-`), `status`, `last_triggered` | +| `Whiteboard` | `whiteboards` | `project_id`, `data` | + +### RBAC + +Roles are **hierarchical integers**, not bitflags. Use `>=` comparisons. + +``` +Viewer = 1 +Editor = 2 +Admin = 4 +Owner = 8 +``` + +Example: `member.RoleFlags >= 2` means Editor or above. + +### Image upload/serve pattern (handler) + +Reference implementation: `server/internal/handlers/teams.go` (UploadTeamAvatar / ServeTeamAvatar). + +```go +// Validate extension +allowedImageExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true} + +// Build storage path +dir := fmt.Sprintf("../data//%s", id.Hex()) +os.MkdirAll(dir, 0755) + +// Delete old file (glob by base name, any extension) +old, _ := filepath.Glob(filepath.Join(dir, "avatar.*")) +for _, f := range old { os.Remove(f) } + +// Save new file +c.SaveFile(fh, filepath.Join(dir, "avatar"+ext)) + +// Update DB with static URL string +database.GetCollection("").UpdateOne(ctx, bson.M{"_id": id}, + bson.M{"$set": bson.M{"avatar_url": "/api//avatar", "updated_at": time.Now()}}) + +// Serve +matches, _ := filepath.Glob(filepath.Join(dir, "avatar.*")) +return c.SendFile(matches[0]) +``` + +Key points: +- `allowedImageExts` is declared once in `teams.go` and reused across the `handlers` package (same package, no redeclaration needed in `users.go`) +- Avatar URL stored in DB is a **static string** (e.g. `/api/users/me/avatar`), not per-extension — the serve endpoint globs at runtime +- Max upload size: GoFiber default (4MB). Adjust `fiber.Config{BodyLimit}` in `main.go` if needed + +### Background jobs + +`startDueDateReminder()` in `main.go` runs every hour and creates `due_soon` notifications for cards due within 1 or 3 days. It deduplicates within a 24-hour window. + +--- + +## Frontend (SvelteKit + Svelte 5 Runes) + +### Rules — non-negotiable + +- **Svelte 5 Runes only**: `$state`, `$derived`, `$props`, `$bindable`. `onMount` is also acceptable. +- **No legacy Svelte**: no `export let`, no reactive `$:`, no `writable` stores. +- `$state` may only be used inside `.svelte`, `.svelte.ts`, or `.svelte.js` files — never in plain `.ts` modules. +- No code comments anywhere. +- No extra files or folders. +- `$page.params.id` must always be written as `$page.params.id ?? ''`. + +### LSP / type errors + +The LSP cache is often stale. **Do not** treat LSP errors as real until confirmed by `bun run build`. Pre-existing LSP errors on `$page`, `Project.visibility`, `Project.is_public`, etc. are known stale artefacts. + +### API client — `src/lib/api/client.ts` + +| Export | Purpose | +|---|---| +| `apiFetch(path, options?)` | JSON fetch, auto-refreshes token on 401, throws on error | +| `apiFetchFormData(path, formData)` | Multipart POST (method hardcoded to POST), same token/retry logic | +| `getAccessToken()` | Returns current in-memory access token | +| `setAccessToken(token)` | Updates in-memory token and localStorage | + +Tokens: +- `access_token` — localStorage + in-memory, 15-minute JWT +- `refresh_token` — localStorage only, 7-day JWT +- `user_id` — localStorage only (used for convenience reads) + +### API methods — `src/lib/api/index.ts` + +All API calls are grouped by resource: `auth`, `users`, `teams`, `projects`, `board`, `cards`, `events`, `notifications`, `docs`, `files`, `webhooks`. Import the group you need: + +```ts +import { users, teams, projects, board, cards } from '$lib/api'; +``` + +### Auth store — `src/lib/stores/auth.svelte.ts` + +```ts +authStore.user // User | null (reactive) +authStore.loading // boolean (reactive) +authStore.init() // call once in root layout onMount +authStore.login(email, password) +authStore.register(name, email, password) +authStore.logout() // calls API, clears tokens, nulls user +authStore.setUser(u) // update user after profile/avatar changes +``` + +### TypeScript interfaces — `src/lib/types/api.ts` + +Mirrors Go models exactly. Always import from here; never inline interfaces. + +Key interfaces: `User`, `Team`, `TeamMember`, `Project`, `ProjectMember`, `Card`, `Subtask`, `Column`, `BoardData`, `Event`, `Notification`, `Doc`, `FileItem`, `Webhook`, `Whiteboard`, `AuthResponse`. + +### File ref resolution — `src/lib/utils/fileRefs.ts` + +`resolveFileRefs(text, files)` replaces `$file:` tokens in Markdown with links. Unmatched refs render as `` `unknown file: ` ``. + +### Tailwind notes + +- Use Tailwind v4 utility classes only. +- `ring-neutral-750` does not exist — use `ring-neutral-800`. +- Never combine `inline-block` with `flex` on the same element — `inline-block` wins and kills flex behaviour. Use `flex` alone. + +--- + +## Common patterns + +### Adding a new API route (end-to-end checklist) + +1. Add handler function in the appropriate `server/internal/handlers/*.go` file. +2. Register the route in `server/cmd/api/main.go` under the right group. +3. Add the typed method to `src/lib/api/index.ts`. +4. If a new response shape is needed, add an interface to `src/lib/types/api.ts`. +5. Call from a `.svelte` page; use `apiFetchFormData` for multipart, `apiFetch` for everything else. +6. Verify: `go build ./...` from `server/` and `bun run build` from root. + +### Archived project read-only enforcement + +`src/routes/(app)/board/[id]/+page.svelte`: +- `isArchived` state derived from `project.is_archived` on mount. +- Yellow banner shown when archived. +- Cards: `draggable={!isArchived}`, click/keydown handlers suppressed, edit button hidden. +- Add Card and Add Column buttons hidden when archived. +- `handleDrop` returns early when `isArchived`. + +### User / team avatar upload (frontend) + +```ts +const updated = await users.uploadAvatar(file); // returns updated User +authStore.setUser(updated); + +const updatedTeam = await teams.uploadAvatar(teamId, file); // returns updated Team +``` + +Display: show `` if `avatar_url` is set, otherwise show the user's initial letter in a coloured circle. + +--- + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `8080` | Server listen port | +| `MONGO_URI` | `mongodb://localhost:27017` | MongoDB connection string | +| `MONGO_DB_NAME` | `fpmb` | MongoDB database name | +| `JWT_SECRET` | — | Required in production | + +`.env` is loaded automatically via `godotenv` in `main.go`. System env vars are used as fallback. + +--- + +## Data directory layout + +Runtime files are stored under `data/` relative to the repository root. The Go server runs from `server/`, so it accesses `../data/`. + +``` +data/ +├── teams// +│ ├── avatar. +│ └── banner. +├── users// +│ └── avatar. +└── projects//files/… +``` + +`data/` is gitignored. + +Goal +Build out and improve the FPMB (Free Project Management Boards) self-hosted project management app at /home/coder/openboard. The current session focused on: user avatar upload, logout button, archived project read-only mode, task card UI fixes, and $file: unknown file handling. The final task (still in progress) is updating README.md and creating BUILD.md with full coding/architecture details for future agent sessions. +--- +Instructions +- Project is at /home/coder/openboard +- No code comments +- No extra files or folders +- Frontend: Svelte 5 Runes only ($state, $derived, $props, $bindable) — onMount is acceptable; no legacy Svelte patterns +- Backend: Go, module path github.com/fpmb/server +- Use bun (not npm): bun run build, bun run dev +- Go is at /usr/bin/go (1.22.2); run from server/ via go run ./cmd/api/main.go +- LSP has a stale cache — use bun run build / go build ./... as the source of truth for real errors; ignore LSP errors that were pre-existing +- $page.params.id must use ?? '' +- RBAC: Viewer=1, Editor=2, Admin=4, Owner=8 — roles are hierarchical (>= not bitwise) +- Server runs from server/ so ../data/ resolves to /home/coder/openboard/data/ +- File storage: ../data/teams//avatar., ../data/users//avatar. +- Use apiFetchFormData in frontend for multipart uploads +- $state may only be used inside .svelte or .svelte.ts/.svelte.js files, not plain .ts modules +--- +Discoveries +Architecture +- allowedImageExts map is defined in server/internal/handlers/teams.go — shared across the handlers package, so users.go reuses it directly (same package, no redeclaration) +- authStore (src/lib/stores/auth.svelte.ts) already has a logout() method that calls the API, clears tokens, and nulls the user — no need to duplicate logic +- apiFetchFormData always uses POST method (hardcoded in client.ts) +- LSP errors on board page ($page, Project.visibility, Project.is_public, etc.) are all pre-existing stale cache issues — builds pass cleanly +- ring-neutral-750 is not a valid Tailwind class (causes white ring fallback); use ring-neutral-800 for card backgrounds +- inline-block + flex conflict — inline-block wins and kills flex centering; use flex alone +- $file: refs are resolved in src/lib/utils/fileRefs.ts via resolveFileRefs() — unmatched refs previously returned raw `$file:name` syntax +- Board page is_archived enforcement: drag-drop guarded in handleDrop, template uses {#if !isArchived} to hide Add Card / Add Column, draggable={!isArchived} on cards +Key patterns +- Team/user image upload: validate ext with allowedImageExts, glob-delete old file, SaveFile, UpdateOne with URL, return updated document +- Serve image: glob-find by extension, SendFile +- Avatar URL stored as /api/users/me/avatar (static string, not per-extension) — the serve endpoint finds the file at runtime +--- +Accomplished +✅ Completed this session +1. User avatar upload — Full end-to-end: + - server/internal/handlers/users.go: Added UploadUserAvatar and ServeUserAvatar handlers + - server/cmd/api/main.go: Registered POST /users/me/avatar and GET /users/me/avatar + - src/lib/api/index.ts: Added users.uploadAvatar(file) + - src/routes/(app)/settings/user/+page.svelte: Added avatar display ( if set, else initial letter), file input upload button, success/error feedback, calls authStore.setUser(updated) +2. Logout button — Added to top navbar in src/routes/(app)/+layout.svelte: + - Arrow-out icon button to the right of the user avatar (desktop only, hidden md:flex) + - Calls authStore.logout() then goto('/login') +3. Archived project read-only mode — src/routes/(app)/board/[id]/+page.svelte: + - Added isArchived state, populated from project.is_archived on mount + - Yellow archived banner shown when isArchived is true + - Cards: draggable={!isArchived}, click/keydown suppressed, edit ... button hidden + - "Add Card" button hidden per column when archived + - "Add Column" button hidden when archived + - handleDrop returns early when isArchived +4. Task card assignee ring fix — ring-neutral-750 → ring-neutral-800, inline-block removed (was conflicting with flex centering) +5. $file: unknown file fallback — src/lib/utils/fileRefs.ts: unmatched refs now render as `unknown file: ` +🔄 In Progress — Documentation update +- README.md — needs updating to reflect: user avatar upload, team avatar/banner, logout button, archived read-only mode, new API routes +- BUILD.md — needs to be created as a comprehensive reference for future agentic coding sessions, covering all architecture, conventions, patterns, file layout, API routes, models, and coding rules +--- +Relevant files / directories +Backend (Go) +server/ +├── cmd/api/main.go routes registration (recently: user avatar routes added) +└── internal/ + ├── handlers/ + │ ├── auth.go + │ ├── teams.go reference: allowedImageExts, uploadTeamImage/serveTeamImage pattern + │ ├── users.go recently added: UploadUserAvatar, ServeUserAvatar + │ ├── projects.go + │ ├── board.go + │ ├── cards.go + │ ├── files.go + │ ├── notifications.go + │ ├── docs.go + │ ├── events.go + │ ├── webhooks.go + │ └── whiteboard.go + ├── middleware/ + │ └── auth.go + ├── models/ + │ └── models.go source of truth for all field names/types + └── database/ + └── db.go +Frontend +src/ +├── lib/ +│ ├── api/ +│ │ ├── client.ts apiFetch, apiFetchFormData, token management +│ │ └── index.ts all API methods (recently: users.uploadAvatar added) +│ ├── components/ +│ │ ├── Markdown/Markdown.svelte +│ │ └── Modal/Modal.svelte +│ ├── stores/ +│ │ └── auth.svelte.ts authStore: init, login, register, logout, setUser +│ ├── types/ +│ │ └── api.ts all TypeScript interfaces +│ └── utils/ +│ └── fileRefs.ts resolveFileRefs (recently: unknown file fallback fixed) +└── routes/ + └── (app)/ + ├── +layout.svelte navbar, auth guard (recently: logout button added) + ├── board/[id]/+page.svelte kanban board (recently: archived read-only mode) + ├── settings/user/+page.svelte user settings (recently: avatar upload UI) + ├── team/[id]/ + │ ├── +page.svelte team overview (shows avatar/banner) + │ └── settings/+page.svelte team settings (avatar/banner upload) + └── projects/ + ├── +page.svelte project list (shows is_archived badge) + └── [id]/settings/+page.svelte project settings (archive/delete) +Docs (in progress) +/home/coder/openboard/README.md needs update +/home/coder/openboard/BUILD.md needs to be created +Data directory (runtime) +/home/coder/openboard/data/ +├── teams//avatar. +├── teams//banner. +└── users//avatar. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ab4bfc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine AS frontend +WORKDIR /app +COPY package.json bun.lockb* ./ +RUN npm install +COPY . . +RUN npm run build + +FROM golang:1.24-alpine AS backend +WORKDIR /app/server +COPY server/go.mod server/go.sum ./ +RUN go mod download +COPY server/ . +RUN CGO_ENABLED=0 GOOS=linux go build -o /fpmb-server ./cmd/api/main.go + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +WORKDIR /app/server +COPY --from=backend /fpmb-server ./fpmb-server +COPY --from=frontend /app/build ../build +COPY --from=frontend /app/static ../static +RUN mkdir -p ../data +EXPOSE 8080 +CMD ["./fpmb-server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1faa19d --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# FPMB — Free Project Management Boards + +FPMB is a self-hosted, open-source project management platform. It provides Kanban boards, task tracking, team collaboration, a canvas whiteboard, a knowledge base, a calendar, file management, webhook integrations, API key management, and role-based access control — all in one application. + +## Features + +- **Kanban Boards** — drag-and-drop cards with priority, color labels, due dates, assignees, subtasks, and Markdown descriptions +- **Personal & Team Projects** — create projects scoped to a team or privately for yourself +- **Whiteboard** — full-screen canvas with pen, rectangle, circle, and eraser tools; auto-saves after every stroke +- **Team Docs** — two-pane Markdown knowledge base editor per team +- **Calendar** — month and week views with per-team and per-project event creation +- **File Manager** — per-project, per-team, and personal file/folder browser with upload support +- **Webhooks** — integrations with Discord, GitHub, Gitea, Slack, and custom endpoints +- **Notifications** — inbox with unread indicators, badge count, and mark-as-read +- **API Keys** — personal API keys with granular scopes for programmatic access +- **API Documentation** — built-in interactive API reference page at `/api-docs` +- **RBAC** — hierarchical role flags (Viewer `1`, Editor `2`, Admin `4`, Owner `8`) for fine-grained permission control +- **User Settings** — profile management, avatar upload, password change, and API key management +- **Archived Projects** — projects can be archived; the board becomes read-only (no drag-drop, no card edits, no new cards or columns) +- **Docker Support** — single-command deployment with Docker Compose + +## Tech Stack + +| Layer | Technology | +|---|---| +| Frontend | SvelteKit + Svelte 5 (Runes), TypeScript | +| Styling | Tailwind CSS v4, JetBrains Mono | +| Icons | @iconify/svelte (Lucide + Simple Icons) | +| Markdown | marked + DOMPurify | +| Backend | Go 1.24 + GoFiber v2 | +| Database | MongoDB 7 | +| Auth | JWT (access 15 min, refresh 7 days) + personal API keys | +| Authorization | RBAC hierarchical role flags | +| Deployment | Docker multi-stage build + Docker Compose | + +## Getting Started + +### Prerequisites + +- [Bun](https://bun.sh) 1.x (or Node.js 22+) +- Go 1.24+ +- MongoDB 7+ (local or Atlas) + +### Development + +Install frontend dependencies and start the dev server: + +```bash +bun install +bun run dev +``` + +In a separate terminal, start the backend: + +```bash +cd server +cp example.env .env # edit with your MongoDB URI and secrets +go run ./cmd/api/main.go +``` + +The frontend dev server runs on `http://localhost:5173` and proxies API requests. +The Go server runs on `http://localhost:8080` and serves both the API and the built frontend in production. + +### Production Build (Manual) + +```bash +bun run build +cd server && go build -o bin/fpmb ./cmd/api/main.go +./bin/fpmb +``` + +### Docker (Recommended) + +The easiest way to deploy FPMB: + +```bash +# Start everything (app + MongoDB) +docker compose up -d + +# With custom secrets +JWT_SECRET=my-secret JWT_REFRESH_SECRET=my-refresh-secret docker compose up -d + +# Rebuild after code changes +docker compose up -d --build + +# View logs +docker compose logs -f app + +# Stop +docker compose down +``` + +This starts: +- **fpmb** — the application on port `8080` +- **fpmb-mongo** — MongoDB 7 on port `27017` + +Data is persisted in Docker volumes (`app_data` for uploads, `mongo_data` for the database). + +### Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `8080` | Server listen port | +| `MONGO_URI` | `mongodb://localhost:27017` | MongoDB connection string | +| `MONGO_DB_NAME` | `fpmb` | MongoDB database name | +| `JWT_SECRET` | `changeme-jwt-secret` | Secret for signing access tokens (**change in production**) | +| `JWT_REFRESH_SECRET` | `changeme-refresh-secret` | Secret for signing refresh tokens (**change in production**) | + +## API Overview + +All routes are under `/api`. Protected endpoints require a `Bearer` token (JWT access token or personal API key). + +A full interactive reference is available in-app at `/api-docs`. + +### Authentication + +| Method | Route | Description | +|---|---|---| +| POST | `/auth/register` | Create a new account | +| POST | `/auth/login` | Login — returns access + refresh tokens | +| POST | `/auth/refresh` | Exchange refresh token for new tokens | +| POST | `/auth/logout` | Logout (requires auth) | + +### API Keys + +| Method | Route | Description | +|---|---|---| +| GET | `/users/me/api-keys` | List all active API keys | +| POST | `/users/me/api-keys` | Create a new key with scopes | +| DELETE | `/users/me/api-keys/:keyId` | Revoke an API key | + +**Available scopes:** `read:projects`, `write:projects`, `read:boards`, `write:boards`, `read:teams`, `write:teams`, `read:files`, `write:files`, `read:notifications` + +### Users + +| Method | Route | Description | +|---|---|---| +| GET | `/users/me` | Get current user profile | +| PUT | `/users/me` | Update profile (name, email) | +| PUT | `/users/me/password` | Change password | +| POST | `/users/me/avatar` | Upload avatar (multipart) | +| GET | `/users/me/avatar` | Serve avatar image | +| GET | `/users/search?q=` | Search users by name/email | + +### Teams + +| Method | Route | Description | +|---|---|---| +| GET/POST | `/teams` | List or create teams | +| GET/PUT/DELETE | `/teams/:teamId` | Get, update, or delete a team | +| GET | `/teams/:teamId/members` | List team members | +| POST | `/teams/:teamId/members/invite` | Invite a member | +| PUT/DELETE | `/teams/:teamId/members/:userId` | Update role or remove member | +| GET/POST | `/teams/:teamId/projects` | List or create team projects | +| GET/POST | `/teams/:teamId/events` | List or create team events | +| GET/POST | `/teams/:teamId/docs` | List or create docs | +| GET | `/teams/:teamId/files` | List team files | +| POST | `/teams/:teamId/files/upload` | Upload file (multipart) | +| POST | `/teams/:teamId/avatar` | Upload team avatar | +| POST | `/teams/:teamId/banner` | Upload team banner | + +### Projects + +| Method | Route | Description | +|---|---|---| +| GET/POST | `/projects` | List all or create personal project | +| GET/PUT/DELETE | `/projects/:projectId` | Get, update, or delete project | +| PUT | `/projects/:projectId/archive` | Toggle archive state | +| GET/POST | `/projects/:projectId/members` | List or add members | +| GET | `/projects/:projectId/board` | Get board (columns + cards) | +| POST | `/projects/:projectId/columns` | Create a column | +| POST | `/projects/:projectId/columns/:columnId/cards` | Create a card | +| GET/POST | `/projects/:projectId/events` | List or create events | +| GET | `/projects/:projectId/files` | List project files | +| POST | `/projects/:projectId/files/upload` | Upload file (multipart) | +| GET/POST | `/projects/:projectId/webhooks` | List or create webhooks | +| GET/PUT | `/projects/:projectId/whiteboard` | Get or save whiteboard | + +### Cards, Events, Files, Webhooks, Notifications + +| Method | Route | Description | +|---|---|---| +| PUT/DELETE | `/cards/:cardId` | Update or delete a card | +| PUT | `/cards/:cardId/move` | Move card between columns | +| PUT/DELETE | `/events/:eventId` | Update or delete an event | +| GET | `/files/:fileId/download` | Download a file | +| DELETE | `/files/:fileId` | Delete a file | +| PUT/DELETE | `/webhooks/:webhookId` | Update or delete a webhook | +| PUT | `/webhooks/:webhookId/toggle` | Enable/disable a webhook | +| GET | `/notifications` | List notifications | +| PUT | `/notifications/read-all` | Mark all as read | +| PUT | `/notifications/:notifId/read` | Mark one as read | +| DELETE | `/notifications/:notifId` | Delete a notification | + +## License + +MIT diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6175099 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + app: + build: . + container_name: fpmb + restart: unless-stopped + ports: + - "8080:8080" + environment: + - PORT=8080 + - MONGO_URI=mongodb://mongo:27017 + - MONGO_DB_NAME=fpmb + - JWT_SECRET=${JWT_SECRET:-changeme-jwt-secret} + - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-changeme-refresh-secret} + volumes: + - app_data:/app/data + depends_on: + mongo: + condition: service_healthy + + mongo: + image: mongo:7 + container_name: fpmb-mongo + restart: unless-stopped + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + app_data: + mongo_data: diff --git a/example.env b/example.env new file mode 100644 index 0000000..f52dce8 --- /dev/null +++ b/example.env @@ -0,0 +1,5 @@ +PORT=8080 +MONGO_URI=mongodb://localhost:27017 +MONGO_DB_NAME=fpmb +JWT_SECRET=changeme-jwt-secret +JWT_REFRESH_SECRET=changeme-refresh-secret diff --git a/package.json b/package.json new file mode 100644 index 0000000..78d3102 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "fpmb", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite dev --host", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@iconify/svelte": "^5.2.1", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.53.4", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.1", + "@types/dompurify": "^3.2.0", + "dompurify": "^3.3.1", + "marked": "^17.0.3", + "svelte": "^5.53.6", + "svelte-check": "^4.4.4", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/server/bin/openboard b/server/bin/openboard new file mode 100755 index 0000000..6a95f5d Binary files /dev/null and b/server/bin/openboard differ diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go new file mode 100644 index 0000000..fe6bfd2 --- /dev/null +++ b/server/cmd/api/main.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/handlers" + "github.com/fpmb/server/internal/middleware" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/websocket/v2" + "github.com/joho/godotenv" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func startDueDateReminder() { + ticker := time.NewTicker(1 * time.Hour) + go func() { + runDueDateReminder() + for range ticker.C { + runDueDateReminder() + } + }() +} + +func runDueDateReminder() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + now := time.Now() + thresholds := []int{1, 3} + + for _, days := range thresholds { + windowStart := now.Add(time.Duration(days)*24*time.Hour - 30*time.Minute) + windowEnd := now.Add(time.Duration(days)*24*time.Hour + 30*time.Minute) + + cursor, err := database.GetCollection("cards").Find(ctx, bson.M{ + "due_date": bson.M{"$gte": windowStart, "$lte": windowEnd}, + }) + if err != nil { + continue + } + + var cards []models.Card + cursor.All(ctx, &cards) + cursor.Close(ctx) + + for _, card := range cards { + for _, email := range card.Assignees { + var user models.User + if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": email}).Decode(&user); err != nil { + continue + } + + cutoff := now.Add(-24 * time.Hour) + count, _ := database.GetCollection("notifications").CountDocuments(ctx, bson.M{ + "user_id": user.ID, + "type": "due_soon", + "card_id": card.ID, + "created_at": bson.M{"$gte": cutoff}, + }) + if count > 0 { + continue + } + + msg := fmt.Sprintf("Task \"%s\" is due in %d day(s)", card.Title, days) + n := &models.Notification{ + ID: primitive.NewObjectID(), + UserID: user.ID, + Type: "due_soon", + Message: msg, + ProjectID: card.ProjectID, + CardID: card.ID, + Read: false, + CreatedAt: now, + } + database.GetCollection("notifications").InsertOne(ctx, n) + } + } + } +} + +func main() { + err := godotenv.Load() + if err != nil { + log.Println("No .env file found, using system environment variables") + } + + database.Connect() + startDueDateReminder() + + app := fiber.New(fiber.Config{ + AppName: "FPMB API", + }) + + app.Use(logger.New(logger.Config{ + Next: func(c *fiber.Ctx) bool { + return len(c.Path()) >= 5 && c.Path()[:5] == "/_app" || + c.Path() == "/favicon.ico" || + len(c.Path()) >= 7 && c.Path()[:7] == "/fonts/" + }, + })) + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", + AllowHeaders: "Origin, Content-Type, Accept, Authorization", + })) + + api := app.Group("/api") + + api.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "ok", "message": "FPMB API is running"}) + }) + + auth := api.Group("/auth") + auth.Post("/register", handlers.Register) + auth.Post("/login", handlers.Login) + auth.Post("/refresh", handlers.RefreshToken) + auth.Post("/logout", middleware.Protected(), handlers.Logout) + + users := api.Group("/users", middleware.Protected()) + users.Get("/me", handlers.GetMe) + users.Put("/me", handlers.UpdateMe) + users.Put("/me/password", handlers.ChangePassword) + users.Get("/search", handlers.SearchUsers) + users.Post("/me/avatar", handlers.UploadUserAvatar) + users.Get("/me/avatar", handlers.ServeUserAvatar) + users.Get("/me/files", handlers.ListUserFiles) + users.Post("/me/files/folder", handlers.CreateUserFolder) + users.Post("/me/files/upload", handlers.UploadUserFile) + users.Get("/me/api-keys", handlers.ListAPIKeys) + users.Post("/me/api-keys", handlers.CreateAPIKey) + users.Delete("/me/api-keys/:keyId", handlers.RevokeAPIKey) + + teams := api.Group("/teams", middleware.Protected()) + teams.Get("/", handlers.ListTeams) + teams.Post("/", handlers.CreateTeam) + teams.Get("/:teamId", handlers.GetTeam) + teams.Put("/:teamId", handlers.UpdateTeam) + teams.Delete("/:teamId", handlers.DeleteTeam) + teams.Get("/:teamId/members", handlers.ListTeamMembers) + teams.Post("/:teamId/members/invite", handlers.InviteTeamMember) + teams.Put("/:teamId/members/:userId", handlers.UpdateTeamMemberRole) + teams.Delete("/:teamId/members/:userId", handlers.RemoveTeamMember) + teams.Get("/:teamId/projects", handlers.ListTeamProjects) + teams.Post("/:teamId/projects", handlers.CreateProject) + teams.Get("/:teamId/events", handlers.ListTeamEvents) + teams.Post("/:teamId/events", handlers.CreateTeamEvent) + teams.Get("/:teamId/docs", handlers.ListDocs) + teams.Post("/:teamId/docs", handlers.CreateDoc) + teams.Get("/:teamId/files", handlers.ListTeamFiles) + teams.Post("/:teamId/files/folder", handlers.CreateTeamFolder) + teams.Post("/:teamId/files/upload", handlers.UploadTeamFile) + teams.Post("/:teamId/avatar", handlers.UploadTeamAvatar) + teams.Get("/:teamId/avatar", handlers.ServeTeamAvatar) + teams.Post("/:teamId/banner", handlers.UploadTeamBanner) + teams.Get("/:teamId/banner", handlers.ServeTeamBanner) + teams.Get("/:teamId/chat", handlers.ListChatMessages) + + projects := api.Group("/projects", middleware.Protected()) + projects.Get("/", handlers.ListProjects) + projects.Post("/", handlers.CreatePersonalProject) + projects.Get("/:projectId", handlers.GetProject) + projects.Put("/:projectId", handlers.UpdateProject) + projects.Put("/:projectId/archive", handlers.ArchiveProject) + projects.Delete("/:projectId", handlers.DeleteProject) + projects.Get("/:projectId/members", handlers.ListProjectMembers) + projects.Post("/:projectId/members", handlers.AddProjectMember) + projects.Put("/:projectId/members/:userId", handlers.UpdateProjectMemberRole) + projects.Delete("/:projectId/members/:userId", handlers.RemoveProjectMember) + projects.Get("/:projectId/board", handlers.GetBoard) + projects.Post("/:projectId/columns", handlers.CreateColumn) + projects.Put("/:projectId/columns/:columnId", handlers.UpdateColumn) + projects.Put("/:projectId/columns/:columnId/position", handlers.ReorderColumn) + projects.Delete("/:projectId/columns/:columnId", handlers.DeleteColumn) + projects.Post("/:projectId/columns/:columnId/cards", handlers.CreateCard) + projects.Get("/:projectId/events", handlers.ListProjectEvents) + projects.Post("/:projectId/events", handlers.CreateProjectEvent) + projects.Get("/:projectId/files", handlers.ListFiles) + projects.Post("/:projectId/files/folder", handlers.CreateFolder) + projects.Post("/:projectId/files/upload", handlers.UploadFile) + projects.Get("/:projectId/webhooks", handlers.ListWebhooks) + projects.Post("/:projectId/webhooks", handlers.CreateWebhook) + projects.Get("/:projectId/whiteboard", handlers.GetWhiteboard) + projects.Put("/:projectId/whiteboard", handlers.SaveWhiteboard) + + cards := api.Group("/cards", middleware.Protected()) + cards.Put("/:cardId", handlers.UpdateCard) + cards.Put("/:cardId/move", handlers.MoveCard) + cards.Delete("/:cardId", handlers.DeleteCard) + + events := api.Group("/events", middleware.Protected()) + events.Put("/:eventId", handlers.UpdateEvent) + events.Delete("/:eventId", handlers.DeleteEvent) + + notifications := api.Group("/notifications", middleware.Protected()) + notifications.Get("/", handlers.ListNotifications) + notifications.Put("/read-all", handlers.MarkAllNotificationsRead) + notifications.Put("/:notifId/read", handlers.MarkNotificationRead) + notifications.Delete("/:notifId", handlers.DeleteNotification) + + docs := api.Group("/docs", middleware.Protected()) + docs.Get("/:docId", handlers.GetDoc) + docs.Put("/:docId", handlers.UpdateDoc) + docs.Delete("/:docId", handlers.DeleteDoc) + + files := api.Group("/files", middleware.Protected()) + files.Get("/:fileId/download", handlers.DownloadFile) + files.Delete("/:fileId", handlers.DeleteFile) + + webhooks := api.Group("/webhooks", middleware.Protected()) + webhooks.Put("/:webhookId", handlers.UpdateWebhook) + webhooks.Put("/:webhookId/toggle", handlers.ToggleWebhook) + webhooks.Delete("/:webhookId", handlers.DeleteWebhook) + + app.Use("/ws", func(c *fiber.Ctx) error { + if websocket.IsWebSocketUpgrade(c) { + return c.Next() + } + return fiber.ErrUpgradeRequired + }) + app.Get("/ws/whiteboard/:id", websocket.New(handlers.WhiteboardWS)) + app.Get("/ws/team/:id/chat", websocket.New(handlers.TeamChatWS)) + + app.Static("/", "../build") + app.Get("/*", func(c *fiber.Ctx) error { + if len(c.Path()) > 4 && c.Path()[:4] == "/api" { + return c.Status(404).JSON(fiber.Map{"error": "Not Found"}) + } + return c.SendFile("../build/index.html") + }) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Server starting on port %s", port) + log.Fatal(app.Listen(":" + port)) +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..16bd51b --- /dev/null +++ b/server/go.mod @@ -0,0 +1,36 @@ +module github.com/fpmb/server + +go 1.24.0 + +require ( + github.com/gofiber/fiber/v2 v2.52.12 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/joho/godotenv v1.5.1 + go.mongodb.org/mongo-driver v1.17.9 + golang.org/x/crypto v0.48.0 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/fasthttp/websocket v1.5.3 // indirect + github.com/gofiber/websocket/v2 v2.2.1 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..3d8edcd --- /dev/null +++ b/server/go.sum @@ -0,0 +1,85 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= +github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= +github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= +github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= +github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/server/internal/database/database.go b/server/internal/database/database.go new file mode 100644 index 0000000..8bcce03 --- /dev/null +++ b/server/internal/database/database.go @@ -0,0 +1,50 @@ +package database + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var DB *mongo.Database +var Client *mongo.Client + +func Connect() { + uri := os.Getenv("MONGO_URI") + if uri == "" { + uri = "mongodb://localhost:27017" + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + clientOptions := options.Client().ApplyURI(uri) + client, err := mongo.Connect(ctx, clientOptions) + if err != nil { + log.Fatal("Failed to connect to MongoDB: ", err) + } + + err = client.Ping(ctx, nil) + if err != nil { + log.Fatal("Failed to ping MongoDB: ", err) + } + + fmt.Println("Successfully connected to MongoDB!") + + Client = client + + dbName := os.Getenv("MONGO_DB_NAME") + if dbName == "" { + dbName = "fpmb" + } + DB = client.Database(dbName) +} + +func GetCollection(collectionName string) *mongo.Collection { + return DB.Collection(collectionName) +} diff --git a/server/internal/handlers/apikeys.go b/server/internal/handlers/apikeys.go new file mode 100644 index 0000000..4ddc5b4 --- /dev/null +++ b/server/internal/handlers/apikeys.go @@ -0,0 +1,165 @@ +package handlers + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// generateAPIKey returns a 32-byte random hex token (64 chars) prefixed with "fpmb_". +func generateAPIKey() (raw string, hashed string, err error) { + b := make([]byte, 32) + if _, err = rand.Read(b); err != nil { + return + } + raw = "fpmb_" + hex.EncodeToString(b) + sum := sha256.Sum256([]byte(raw)) + hashed = hex.EncodeToString(sum[:]) + return +} + +// ListAPIKeys returns all non-revoked API keys for the current user (without exposing hashes). +func ListAPIKeys(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cursor, err := database.GetCollection("api_keys").Find(ctx, bson.M{ + "user_id": userID, + "revoked_at": bson.M{"$exists": false}, + }) + if err != nil { + return c.JSON([]fiber.Map{}) + } + defer cursor.Close(ctx) + + var keys []models.APIKey + cursor.All(ctx, &keys) + + // Strip the hash before returning. + type SafeKey struct { + ID string `json:"id"` + Name string `json:"name"` + Scopes []string `json:"scopes"` + Prefix string `json:"prefix"` + LastUsed *time.Time `json:"last_used,omitempty"` + CreatedAt time.Time `json:"created_at"` + } + result := []SafeKey{} + for _, k := range keys { + result = append(result, SafeKey{ + ID: k.ID.Hex(), + Name: k.Name, + Scopes: k.Scopes, + Prefix: k.Prefix, + LastUsed: k.LastUsed, + CreatedAt: k.CreatedAt, + }) + } + return c.JSON(result) +} + +// CreateAPIKey generates a new API key and stores its hash. +func CreateAPIKey(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + var body struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + } + if err := c.BodyParser(&body); err != nil || body.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + } + if len(body.Scopes) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "at least one scope is required"}) + } + + // Validate scopes. + valid := map[string]bool{ + "read:projects": true, "write:projects": true, + "read:boards": true, "write:boards": true, + "read:teams": true, "write:teams": true, + "read:files": true, "write:files": true, + "read:notifications": true, + } + for _, s := range body.Scopes { + if !valid[s] { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "unknown scope: " + s}) + } + } + + raw, hashed, err := generateAPIKey() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate key"}) + } + + now := time.Now() + key := models.APIKey{ + ID: primitive.NewObjectID(), + UserID: userID, + Name: body.Name, + Scopes: body.Scopes, + KeyHash: hashed, + Prefix: raw[:10], // "fpmb_" + first 5 chars of random + CreatedAt: now, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := database.GetCollection("api_keys").InsertOne(ctx, key); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to store key"}) + } + + // Return the raw key only once. + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "id": key.ID.Hex(), + "name": key.Name, + "scopes": key.Scopes, + "prefix": key.Prefix, + "key": raw, + "created_at": key.CreatedAt, + }) +} + +// RevokeAPIKey soft-deletes an API key belonging to the current user. +func RevokeAPIKey(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + keyID, err := primitive.ObjectIDFromHex(c.Params("keyId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid key ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + now := time.Now() + res, err := database.GetCollection("api_keys").UpdateOne(ctx, + bson.M{"_id": keyID, "user_id": userID}, + bson.M{"$set": bson.M{"revoked_at": now}}, + ) + if err != nil || res.MatchedCount == 0 { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Key not found"}) + } + + return c.JSON(fiber.Map{"message": "Key revoked"}) +} diff --git a/server/internal/handlers/auth.go b/server/internal/handlers/auth.go new file mode 100644 index 0000000..2faa1e8 --- /dev/null +++ b/server/internal/handlers/auth.go @@ -0,0 +1,199 @@ +package handlers + +import ( + "context" + "os" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/middleware" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "golang.org/x/crypto/bcrypt" +) + +func jwtSecret() []byte { + s := os.Getenv("JWT_SECRET") + if s == "" { + s = "changeme-jwt-secret" + } + return []byte(s) +} + +func jwtRefreshSecret() []byte { + s := os.Getenv("JWT_REFRESH_SECRET") + if s == "" { + s = "changeme-refresh-secret" + } + return []byte(s) +} + +func generateTokens(user *models.User) (string, string, error) { + accessClaims := &middleware.JWTClaims{ + UserID: user.ID.Hex(), + Email: user.Email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(jwtSecret()) + if err != nil { + return "", "", err + } + + refreshClaims := &middleware.JWTClaims{ + UserID: user.ID.Hex(), + Email: user.Email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(jwtRefreshSecret()) + if err != nil { + return "", "", err + } + + return accessToken, refreshToken, nil +} + +func Register(c *fiber.Ctx) error { + var body struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if body.Name == "" || body.Email == "" || body.Password == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name, email and password are required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + col := database.GetCollection("users") + existing := col.FindOne(ctx, bson.M{"email": body.Email}) + if existing.Err() == nil { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Email already in use"}) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"}) + } + + now := time.Now() + user := &models.User{ + ID: primitive.NewObjectID(), + Name: body.Name, + Email: body.Email, + PasswordHash: string(hash), + CreatedAt: now, + UpdatedAt: now, + } + + if _, err := col.InsertOne(ctx, user); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"}) + } + + access, refresh, err := generateTokens(user) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate tokens"}) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "access_token": access, + "refresh_token": refresh, + "user": fiber.Map{"id": user.ID, "name": user.Name, "email": user.Email}, + }) +} + +func Login(c *fiber.Ctx) error { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if body.Email == "" || body.Password == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email and password are required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var user models.User + err := database.GetCollection("users").FindOne(ctx, bson.M{"email": body.Email}).Decode(&user) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) + } + + access, refresh, err := generateTokens(&user) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate tokens"}) + } + + return c.JSON(fiber.Map{ + "access_token": access, + "refresh_token": refresh, + "user": fiber.Map{"id": user.ID, "name": user.Name, "email": user.Email}, + }) +} + +func RefreshToken(c *fiber.Ctx) error { + var body struct { + RefreshToken string `json:"refresh_token"` + } + if err := c.BodyParser(&body); err != nil || body.RefreshToken == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "refresh_token is required"}) + } + + claims := &middleware.JWTClaims{} + token, err := jwt.ParseWithClaims(body.RefreshToken, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fiber.ErrUnauthorized + } + return jwtRefreshSecret(), nil + }) + if err != nil || !token.Valid { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired refresh token"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + userID, err := primitive.ObjectIDFromHex(claims.UserID) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid token claims"}) + } + + var user models.User + if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User not found"}) + } + + access, newRefresh, err := generateTokens(&user) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate tokens"}) + } + + return c.JSON(fiber.Map{ + "access_token": access, + "refresh_token": newRefresh, + }) +} + +func Logout(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"message": "Logged out successfully"}) +} diff --git a/server/internal/handlers/board.go b/server/internal/handlers/board.go new file mode 100644 index 0000000..ee80ab9 --- /dev/null +++ b/server/internal/handlers/board.go @@ -0,0 +1,484 @@ +package handlers + +import ( + "context" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func GetBoard(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getProjectRole(ctx, projectID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + colCursor, err := database.GetCollection("board_columns").Find(ctx, + bson.M{"project_id": projectID}, + options.Find().SetSort(bson.M{"position": 1})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch columns"}) + } + defer colCursor.Close(ctx) + + var columns []models.BoardColumn + colCursor.All(ctx, &columns) + + type ColumnWithCards struct { + models.BoardColumn + Cards []models.Card `json:"cards"` + } + + result := []ColumnWithCards{} + for _, col := range columns { + cardCursor, err := database.GetCollection("cards").Find(ctx, + bson.M{"column_id": col.ID}, + options.Find().SetSort(bson.M{"position": 1})) + if err != nil { + result = append(result, ColumnWithCards{BoardColumn: col, Cards: []models.Card{}}) + continue + } + var cards []models.Card + cardCursor.All(ctx, &cards) + cardCursor.Close(ctx) + if cards == nil { + cards = []models.Card{} + } + result = append(result, ColumnWithCards{BoardColumn: col, Cards: cards}) + } + + return c.JSON(fiber.Map{"project_id": projectID, "columns": result}) +} + +func CreateColumn(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + var body struct { + Title string `json:"title"` + } + if err := c.BodyParser(&body); err != nil || body.Title == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Title is required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + count, _ := database.GetCollection("board_columns").CountDocuments(ctx, bson.M{"project_id": projectID}) + now := time.Now() + col := &models.BoardColumn{ + ID: primitive.NewObjectID(), + ProjectID: projectID, + Title: body.Title, + Position: int(count), + CreatedAt: now, + UpdatedAt: now, + } + + database.GetCollection("board_columns").InsertOne(ctx, col) + return c.Status(fiber.StatusCreated).JSON(col) +} + +func UpdateColumn(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + columnID, err := primitive.ObjectIDFromHex(c.Params("columnId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"}) + } + + var body struct { + Title string `json:"title"` + } + c.BodyParser(&body) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + update := bson.M{"updated_at": time.Now()} + if body.Title != "" { + update["title"] = body.Title + } + + col := database.GetCollection("board_columns") + col.UpdateOne(ctx, bson.M{"_id": columnID, "project_id": projectID}, bson.M{"$set": update}) + + var column models.BoardColumn + col.FindOne(ctx, bson.M{"_id": columnID}).Decode(&column) + return c.JSON(column) +} + +func ReorderColumn(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + columnID, err := primitive.ObjectIDFromHex(c.Params("columnId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"}) + } + + var body struct { + Position int `json:"position"` + } + c.BodyParser(&body) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("board_columns").UpdateOne(ctx, + bson.M{"_id": columnID, "project_id": projectID}, + bson.M{"$set": bson.M{"position": body.Position, "updated_at": time.Now()}}, + ) + return c.JSON(fiber.Map{"id": columnID, "position": body.Position}) +} + +func DeleteColumn(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + columnID, err := primitive.ObjectIDFromHex(c.Params("columnId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("board_columns").DeleteOne(ctx, bson.M{"_id": columnID, "project_id": projectID}) + database.GetCollection("cards").DeleteMany(ctx, bson.M{"column_id": columnID}) + return c.JSON(fiber.Map{"message": "Column deleted"}) +} + +func CreateCard(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + columnID, err := primitive.ObjectIDFromHex(c.Params("columnId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"}) + } + + var body struct { + Title string `json:"title"` + Description string `json:"description"` + Priority string `json:"priority"` + Color string `json:"color"` + DueDate string `json:"due_date"` + Assignees []string `json:"assignees"` + Subtasks []models.Subtask `json:"subtasks"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if body.Title == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Title is required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + count, _ := database.GetCollection("cards").CountDocuments(ctx, bson.M{"column_id": columnID}) + now := time.Now() + + if body.Assignees == nil { + body.Assignees = []string{} + } + if body.Subtasks == nil { + body.Subtasks = []models.Subtask{} + } + if body.Priority == "" { + body.Priority = "Medium" + } + if body.Color == "" { + body.Color = "neutral" + } + + var dueDate *time.Time + if body.DueDate != "" { + if parsed, parseErr := time.Parse("2006-01-02", body.DueDate); parseErr == nil { + dueDate = &parsed + } + } + + card := &models.Card{ + ID: primitive.NewObjectID(), + ColumnID: columnID, + ProjectID: projectID, + Title: body.Title, + Description: body.Description, + Priority: body.Priority, + Color: body.Color, + DueDate: dueDate, + Assignees: body.Assignees, + Subtasks: body.Subtasks, + Position: int(count), + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + database.GetCollection("cards").InsertOne(ctx, card) + + for _, email := range card.Assignees { + var assignee models.User + if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": email}).Decode(&assignee); err != nil { + continue + } + if assignee.ID == userID { + continue + } + createNotification(ctx, assignee.ID, "assign", + "You have been assigned to the task \""+card.Title+"\"", + card.ProjectID, card.ID) + } + + return c.Status(fiber.StatusCreated).JSON(card) +} + +func UpdateCard(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + cardID, err := primitive.ObjectIDFromHex(c.Params("cardId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var existing models.Card + if err := database.GetCollection("cards").FindOne(ctx, bson.M{"_id": cardID}).Decode(&existing); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Card not found"}) + } + + roleFlags, err := getProjectRole(ctx, existing.ProjectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + var body struct { + Title *string `json:"title"` + Description *string `json:"description"` + Priority *string `json:"priority"` + Color *string `json:"color"` + DueDate *string `json:"due_date"` + Assignees []string `json:"assignees"` + Subtasks []models.Subtask `json:"subtasks"` + } + c.BodyParser(&body) + + update := bson.M{"updated_at": time.Now()} + if body.Title != nil { + update["title"] = *body.Title + } + if body.Description != nil { + update["description"] = *body.Description + } + if body.Priority != nil { + update["priority"] = *body.Priority + } + if body.Color != nil { + update["color"] = *body.Color + } + if body.DueDate != nil { + if *body.DueDate == "" { + update["due_date"] = nil + } else if parsed, parseErr := time.Parse("2006-01-02", *body.DueDate); parseErr == nil { + update["due_date"] = parsed + } + } + if body.Assignees != nil { + update["assignees"] = body.Assignees + } + if body.Subtasks != nil { + update["subtasks"] = body.Subtasks + } + + col := database.GetCollection("cards") + col.UpdateOne(ctx, bson.M{"_id": cardID}, bson.M{"$set": update}) + + var card models.Card + col.FindOne(ctx, bson.M{"_id": cardID}).Decode(&card) + + if body.Assignees != nil { + existingSet := make(map[string]bool) + for _, e := range existing.Assignees { + existingSet[e] = true + } + for _, email := range body.Assignees { + if existingSet[email] { + continue + } + var assignee models.User + if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": email}).Decode(&assignee); err != nil { + continue + } + if assignee.ID == userID { + continue + } + createNotification(ctx, assignee.ID, "assign", + "You have been assigned to the task \""+card.Title+"\"", + card.ProjectID, card.ID) + } + } + + return c.JSON(card) +} + +func MoveCard(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + cardID, err := primitive.ObjectIDFromHex(c.Params("cardId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"}) + } + + var body struct { + ColumnID string `json:"column_id"` + Position int `json:"position"` + } + if err := c.BodyParser(&body); err != nil || body.ColumnID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "column_id is required"}) + } + + newColumnID, err := primitive.ObjectIDFromHex(body.ColumnID) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column_id"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var card models.Card + if err := database.GetCollection("cards").FindOne(ctx, bson.M{"_id": cardID}).Decode(&card); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Card not found"}) + } + + roleFlags, err := getProjectRole(ctx, card.ProjectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + col := database.GetCollection("cards") + col.UpdateOne(ctx, bson.M{"_id": cardID}, bson.M{"$set": bson.M{ + "column_id": newColumnID, + "position": body.Position, + "updated_at": time.Now(), + }}) + + var updated models.Card + col.FindOne(ctx, bson.M{"_id": cardID}).Decode(&updated) + return c.JSON(updated) +} + +func DeleteCard(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + cardID, err := primitive.ObjectIDFromHex(c.Params("cardId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var card models.Card + if err := database.GetCollection("cards").FindOne(ctx, bson.M{"_id": cardID}).Decode(&card); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Card not found"}) + } + + roleFlags, err := getProjectRole(ctx, card.ProjectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("cards").DeleteOne(ctx, bson.M{"_id": cardID}) + return c.JSON(fiber.Map{"message": "Card deleted"}) +} diff --git a/server/internal/handlers/chat.go b/server/internal/handlers/chat.go new file mode 100644 index 0000000..9fa79b0 --- /dev/null +++ b/server/internal/handlers/chat.go @@ -0,0 +1,238 @@ +package handlers + +import ( + "context" + "encoding/json" + "strings" + "sync" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/websocket/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type chatRoom struct { + clients map[*websocket.Conn]*wsClient + mu sync.RWMutex +} + +var chatRooms = struct { + m map[string]*chatRoom + mu sync.RWMutex +}{m: make(map[string]*chatRoom)} + +func getChatRoom(teamID string) *chatRoom { + chatRooms.mu.Lock() + defer chatRooms.mu.Unlock() + if room, ok := chatRooms.m[teamID]; ok { + return room + } + room := &chatRoom{clients: make(map[*websocket.Conn]*wsClient)} + chatRooms.m[teamID] = room + return room +} + +func (r *chatRoom) broadcast(sender *websocket.Conn, msg []byte) { + r.mu.RLock() + defer r.mu.RUnlock() + for conn := range r.clients { + if conn != sender { + _ = conn.WriteMessage(websocket.TextMessage, msg) + } + } +} + +func (r *chatRoom) broadcastAll(msg []byte) { + r.mu.RLock() + defer r.mu.RUnlock() + for conn := range r.clients { + _ = conn.WriteMessage(websocket.TextMessage, msg) + } +} + +func (r *chatRoom) onlineUsers() []map[string]string { + r.mu.RLock() + defer r.mu.RUnlock() + seen := map[string]bool{} + list := make([]map[string]string, 0) + for _, c := range r.clients { + if !seen[c.userID] { + seen[c.userID] = true + list = append(list, map[string]string{"user_id": c.userID, "name": c.name}) + } + } + return list +} + +func ListChatMessages(c *fiber.Ctx) error { + teamID := c.Params("teamId") + teamOID, err := primitive.ObjectIDFromHex(teamID) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + limitStr := c.Query("limit", "50") + limit := int64(50) + if l, err := primitive.ParseDecimal128(limitStr); err == nil { + if s := l.String(); s != "" { + if n, err := parseIntFromString(s); err == nil && n > 0 && n <= 200 { + limit = n + } + } + } + + beforeStr := c.Query("before", "") + filter := bson.M{"team_id": teamOID} + if beforeStr != "" { + if beforeID, err := primitive.ObjectIDFromHex(beforeStr); err == nil { + filter["_id"] = bson.M{"$lt": beforeID} + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + opts := options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(limit) + cursor, err := database.GetCollection("chat_messages").Find(ctx, filter, opts) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to fetch messages"}) + } + defer cursor.Close(ctx) + + var messages []models.ChatMessage + if err := cursor.All(ctx, &messages); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to decode messages"}) + } + if messages == nil { + messages = []models.ChatMessage{} + } + + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } + + return c.JSON(messages) +} + +func parseIntFromString(s string) (int64, error) { + var n int64 + for _, ch := range s { + if ch < '0' || ch > '9' { + break + } + n = n*10 + int64(ch-'0') + } + return n, nil +} + +func TeamChatWS(c *websocket.Conn) { + teamID := c.Params("id") + tokenStr := c.Query("token", "") + userName := c.Query("name", "Anonymous") + + userID, _, ok := parseWSToken(tokenStr) + if !ok { + _ = c.WriteJSON(map[string]string{"type": "error", "message": "unauthorized"}) + _ = c.Close() + return + } + + room := getChatRoom(teamID) + + client := &wsClient{conn: c, userID: userID, name: userName} + room.mu.Lock() + room.clients[c] = client + room.mu.Unlock() + + presenceMsg, _ := json.Marshal(map[string]interface{}{ + "type": "presence", + "users": room.onlineUsers(), + }) + room.broadcastAll(presenceMsg) + + defer func() { + room.mu.Lock() + delete(room.clients, c) + empty := len(room.clients) == 0 + room.mu.Unlock() + + leaveMsg, _ := json.Marshal(map[string]interface{}{ + "type": "presence", + "users": room.onlineUsers(), + }) + room.broadcast(nil, leaveMsg) + + if empty { + chatRooms.mu.Lock() + delete(chatRooms.m, teamID) + chatRooms.mu.Unlock() + } + }() + + for { + _, msg, err := c.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { + // unexpected error + } + break + } + + var incoming struct { + Type string `json:"type"` + Content string `json:"content"` + } + if json.Unmarshal(msg, &incoming) != nil { + continue + } + + if incoming.Type == "message" { + content := strings.TrimSpace(incoming.Content) + if content == "" || len(content) > 5000 { + continue + } + + teamOID, err := primitive.ObjectIDFromHex(teamID) + if err != nil { + continue + } + userOID, err := primitive.ObjectIDFromHex(userID) + if err != nil { + continue + } + + chatMsg := models.ChatMessage{ + ID: primitive.NewObjectID(), + TeamID: teamOID, + UserID: userOID, + UserName: userName, + Content: content, + CreatedAt: time.Now(), + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _, _ = database.GetCollection("chat_messages").InsertOne(ctx, chatMsg) + cancel() + + outMsg, _ := json.Marshal(map[string]interface{}{ + "type": "message", + "message": chatMsg, + }) + room.broadcastAll(outMsg) + } + + if incoming.Type == "typing" { + typingMsg, _ := json.Marshal(map[string]interface{}{ + "type": "typing", + "user_id": userID, + "name": userName, + }) + room.broadcast(c, typingMsg) + } + } +} diff --git a/server/internal/handlers/docs.go b/server/internal/handlers/docs.go new file mode 100644 index 0000000..f46d1db --- /dev/null +++ b/server/internal/handlers/docs.go @@ -0,0 +1,221 @@ +package handlers + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func ListDocs(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getTeamRole(ctx, teamID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + cursor, err := database.GetCollection("docs").Find(ctx, + bson.M{"team_id": teamID}, + options.Find().SetSort(bson.M{"updated_at": -1}).SetProjection(bson.M{"content": 0})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch docs"}) + } + defer cursor.Close(ctx) + + var docs []models.Doc + cursor.All(ctx, &docs) + if docs == nil { + docs = []models.Doc{} + } + return c.JSON(docs) +} + +func CreateDoc(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + var body struct { + Title string `json:"title"` + Content string `json:"content"` + } + if err := c.BodyParser(&body); err != nil || body.Title == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Title is required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + now := time.Now() + doc := &models.Doc{ + ID: primitive.NewObjectID(), + TeamID: teamID, + Title: body.Title, + Content: body.Content, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + database.GetCollection("docs").InsertOne(ctx, doc) + + docDir := filepath.Join("../data/teams", teamID.Hex(), "docs") + if err := os.MkdirAll(docDir, 0755); err != nil { + log.Printf("CreateDoc: mkdir %s: %v", docDir, err) + } else { + content := fmt.Sprintf("# %s\n\n%s", doc.Title, doc.Content) + if err := os.WriteFile(filepath.Join(docDir, doc.ID.Hex()+".md"), []byte(content), 0644); err != nil { + log.Printf("CreateDoc: write file: %v", err) + } + } + + return c.Status(fiber.StatusCreated).JSON(doc) +} + +func GetDoc(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + docID, err := primitive.ObjectIDFromHex(c.Params("docId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid doc ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var doc models.Doc + if err := database.GetCollection("docs").FindOne(ctx, bson.M{"_id": docID}).Decode(&doc); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Doc not found"}) + } + + if _, err := getTeamRole(ctx, doc.TeamID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + return c.JSON(doc) +} + +func UpdateDoc(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + docID, err := primitive.ObjectIDFromHex(c.Params("docId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid doc ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var existing models.Doc + if err := database.GetCollection("docs").FindOne(ctx, bson.M{"_id": docID}).Decode(&existing); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Doc not found"}) + } + + roleFlags, err := getTeamRole(ctx, existing.TeamID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + var body struct { + Title string `json:"title"` + Content string `json:"content"` + } + c.BodyParser(&body) + + update := bson.M{"updated_at": time.Now()} + if body.Title != "" { + update["title"] = body.Title + } + if body.Content != "" { + update["content"] = body.Content + } + + col := database.GetCollection("docs") + col.UpdateOne(ctx, bson.M{"_id": docID}, bson.M{"$set": update}) + + var doc models.Doc + col.FindOne(ctx, bson.M{"_id": docID}).Decode(&doc) + + docDir := filepath.Join("../data/teams", existing.TeamID.Hex(), "docs") + if err := os.MkdirAll(docDir, 0755); err != nil { + log.Printf("UpdateDoc: mkdir %s: %v", docDir, err) + } else { + content := fmt.Sprintf("# %s\n\n%s", doc.Title, doc.Content) + if err := os.WriteFile(filepath.Join(docDir, doc.ID.Hex()+".md"), []byte(content), 0644); err != nil { + log.Printf("UpdateDoc: write file: %v", err) + } + } + + return c.JSON(doc) +} + +func DeleteDoc(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + docID, err := primitive.ObjectIDFromHex(c.Params("docId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid doc ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var doc models.Doc + if err := database.GetCollection("docs").FindOne(ctx, bson.M{"_id": docID}).Decode(&doc); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Doc not found"}) + } + + roleFlags, err := getTeamRole(ctx, doc.TeamID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("docs").DeleteOne(ctx, bson.M{"_id": docID}) + + mdPath := filepath.Join("../data/teams", doc.TeamID.Hex(), "docs", docID.Hex()+".md") + if err := os.Remove(mdPath); err != nil && !os.IsNotExist(err) { + log.Printf("DeleteDoc: remove file %s: %v", mdPath, err) + } + + return c.JSON(fiber.Map{"message": "Doc deleted"}) +} diff --git a/server/internal/handlers/events.go b/server/internal/handlers/events.go new file mode 100644 index 0000000..54437ea --- /dev/null +++ b/server/internal/handlers/events.go @@ -0,0 +1,285 @@ +package handlers + +import ( + "context" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func ListTeamEvents(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getTeamRole(ctx, teamID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + filter := bson.M{"scope_id": teamID, "scope": "org"} + if month := c.Query("month"); month != "" { + filter["date"] = bson.M{"$regex": "^" + month} + } + + cursor, err := database.GetCollection("events").Find(ctx, filter, + options.Find().SetSort(bson.M{"date": 1})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch events"}) + } + defer cursor.Close(ctx) + + var events []models.Event + cursor.All(ctx, &events) + if events == nil { + events = []models.Event{} + } + return c.JSON(events) +} + +func CreateTeamEvent(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + var body struct { + Title string `json:"title"` + Date string `json:"date"` + Time string `json:"time"` + Color string `json:"color"` + Description string `json:"description"` + } + if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Date == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "title and date are required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + now := time.Now() + event := &models.Event{ + ID: primitive.NewObjectID(), + Title: body.Title, + Date: body.Date, + Time: body.Time, + Color: body.Color, + Description: body.Description, + Scope: "org", + ScopeID: teamID, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + database.GetCollection("events").InsertOne(ctx, event) + return c.Status(fiber.StatusCreated).JSON(event) +} + +func ListProjectEvents(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getProjectRole(ctx, projectID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + filter := bson.M{"scope_id": projectID, "scope": "project"} + if month := c.Query("month"); month != "" { + filter["date"] = bson.M{"$regex": "^" + month} + } + + cursor, err := database.GetCollection("events").Find(ctx, filter, + options.Find().SetSort(bson.M{"date": 1})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch events"}) + } + defer cursor.Close(ctx) + + var events []models.Event + cursor.All(ctx, &events) + if events == nil { + events = []models.Event{} + } + return c.JSON(events) +} + +func CreateProjectEvent(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + var body struct { + Title string `json:"title"` + Date string `json:"date"` + Time string `json:"time"` + Color string `json:"color"` + Description string `json:"description"` + } + if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Date == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "title and date are required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + now := time.Now() + event := &models.Event{ + ID: primitive.NewObjectID(), + Title: body.Title, + Date: body.Date, + Time: body.Time, + Color: body.Color, + Description: body.Description, + Scope: "project", + ScopeID: projectID, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + database.GetCollection("events").InsertOne(ctx, event) + return c.Status(fiber.StatusCreated).JSON(event) +} + +func UpdateEvent(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + eventID, err := primitive.ObjectIDFromHex(c.Params("eventId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid event ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var event models.Event + if err := database.GetCollection("events").FindOne(ctx, bson.M{"_id": eventID}).Decode(&event); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Event not found"}) + } + + var roleFlags int + var roleErr error + if event.Scope == "org" { + roleFlags, roleErr = getTeamRole(ctx, event.ScopeID, userID) + } else { + roleFlags, roleErr = getProjectRole(ctx, event.ScopeID, userID) + } + if roleErr != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + var body struct { + Title string `json:"title"` + Date string `json:"date"` + Time string `json:"time"` + Color string `json:"color"` + Description string `json:"description"` + } + c.BodyParser(&body) + + update := bson.M{"updated_at": time.Now()} + if body.Title != "" { + update["title"] = body.Title + } + if body.Date != "" { + update["date"] = body.Date + } + if body.Time != "" { + update["time"] = body.Time + } + if body.Color != "" { + update["color"] = body.Color + } + if body.Description != "" { + update["description"] = body.Description + } + + col := database.GetCollection("events") + col.UpdateOne(ctx, bson.M{"_id": eventID}, bson.M{"$set": update}) + + var updated models.Event + col.FindOne(ctx, bson.M{"_id": eventID}).Decode(&updated) + return c.JSON(updated) +} + +func DeleteEvent(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + eventID, err := primitive.ObjectIDFromHex(c.Params("eventId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid event ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var event models.Event + if err := database.GetCollection("events").FindOne(ctx, bson.M{"_id": eventID}).Decode(&event); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Event not found"}) + } + + var roleFlags int + var roleErr error + if event.Scope == "org" { + roleFlags, roleErr = getTeamRole(ctx, event.ScopeID, userID) + } else { + roleFlags, roleErr = getProjectRole(ctx, event.ScopeID, userID) + } + if roleErr != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("events").DeleteOne(ctx, bson.M{"_id": eventID}) + return c.JSON(fiber.Map{"message": "Event deleted"}) +} diff --git a/server/internal/handlers/files.go b/server/internal/handlers/files.go new file mode 100644 index 0000000..8ea1a39 --- /dev/null +++ b/server/internal/handlers/files.go @@ -0,0 +1,590 @@ +package handlers + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func storageBase(ctx context.Context, projectID primitive.ObjectID) (string, error) { + var project models.Project + if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil { + return "", err + } + if project.TeamID == primitive.NilObjectID { + return filepath.Join("../data/users", project.CreatedBy.Hex()), nil + } + return filepath.Join("../data/teams", project.TeamID.Hex()), nil +} + +func ListFiles(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getProjectRole(ctx, projectID, userID); err != nil { + log.Printf("ListFiles getProjectRole error: %v (projectID=%s userID=%s)", err, projectID.Hex(), userID.Hex()) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + filter := bson.M{"project_id": projectID} + if parentID := c.Query("parent_id"); parentID != "" { + oid, err := primitive.ObjectIDFromHex(parentID) + if err == nil { + filter["parent_id"] = oid + } + } else { + filter["parent_id"] = bson.M{"$exists": false} + } + + cursor, err := database.GetCollection("files").Find(ctx, filter, + options.Find().SetSort(bson.D{{Key: "type", Value: -1}, {Key: "name", Value: 1}})) + if err != nil { + log.Printf("ListFiles Find error: %v (filter=%v)", err, filter) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch files"}) + } + defer cursor.Close(ctx) + + var files []models.File + cursor.All(ctx, &files) + if files == nil { + files = []models.File{} + } + return c.JSON(files) +} + +func CreateFolder(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + var body struct { + Name string `json:"name"` + ParentID string `json:"parent_id"` + } + if err := c.BodyParser(&body); err != nil || body.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name is required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + now := time.Now() + file := &models.File{ + ID: primitive.NewObjectID(), + ProjectID: projectID, + Name: body.Name, + Type: "folder", + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + if body.ParentID != "" { + if oid, err := primitive.ObjectIDFromHex(body.ParentID); err == nil { + file.ParentID = &oid + } + } + + database.GetCollection("files").InsertOne(ctx, file) + return c.Status(fiber.StatusCreated).JSON(file) +} + +func UploadFile(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + fh, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"}) + } + + base, err := storageBase(ctx, projectID) + if err != nil { + log.Printf("UploadFile storageBase error: %v (projectID=%s)", err, projectID.Hex()) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve storage path"}) + } + + if err := os.MkdirAll(base, 0755); err != nil { + log.Printf("UploadFile MkdirAll error: %v (base=%s)", err, base) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"}) + } + + filename := fh.Filename + ext := filepath.Ext(filename) + stem := filename[:len(filename)-len(ext)] + destPath := filepath.Join(base, filename) + for n := 2; ; n++ { + if _, statErr := os.Stat(destPath); statErr != nil { + break + } + filename = fmt.Sprintf("%s (%d)%s", stem, n, ext) + destPath = filepath.Join(base, filename) + } + + if err := c.SaveFile(fh, destPath); err != nil { + log.Printf("UploadFile SaveFile error: %v (destPath=%s)", err, destPath) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"}) + } + + relPath := destPath[len("../data/"):] + + now := time.Now() + file := &models.File{ + ID: primitive.NewObjectID(), + ProjectID: projectID, + Name: fh.Filename, + Type: "file", + SizeBytes: fh.Size, + StorageURL: relPath, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + parentID := c.FormValue("parent_id") + if parentID != "" { + if oid, err := primitive.ObjectIDFromHex(parentID); err == nil { + file.ParentID = &oid + } + } + + database.GetCollection("files").InsertOne(ctx, file) + return c.Status(fiber.StatusCreated).JSON(file) +} + +func ListTeamFiles(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getTeamRole(ctx, teamID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + filter := bson.M{"team_id": teamID} + if parentID := c.Query("parent_id"); parentID != "" { + oid, err := primitive.ObjectIDFromHex(parentID) + if err == nil { + filter["parent_id"] = oid + } + } else { + filter["parent_id"] = bson.M{"$exists": false} + } + + cursor, err := database.GetCollection("files").Find(ctx, filter, + options.Find().SetSort(bson.D{{Key: "type", Value: -1}, {Key: "name", Value: 1}})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch files"}) + } + defer cursor.Close(ctx) + + var files []models.File + cursor.All(ctx, &files) + if files == nil { + files = []models.File{} + } + return c.JSON(files) +} + +func CreateTeamFolder(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + var body struct { + Name string `json:"name"` + ParentID string `json:"parent_id"` + } + if err := c.BodyParser(&body); err != nil || body.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name is required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + now := time.Now() + file := &models.File{ + ID: primitive.NewObjectID(), + TeamID: teamID, + Name: body.Name, + Type: "folder", + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + if body.ParentID != "" { + if oid, err := primitive.ObjectIDFromHex(body.ParentID); err == nil { + file.ParentID = &oid + } + } + + database.GetCollection("files").InsertOne(ctx, file) + return c.Status(fiber.StatusCreated).JSON(file) +} + +func UploadTeamFile(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + fh, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"}) + } + + base := filepath.Join("../data/teams", teamID.Hex(), "files") + if err := os.MkdirAll(base, 0755); err != nil { + log.Printf("UploadTeamFile MkdirAll error: %v (base=%s)", err, base) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"}) + } + + filename := fh.Filename + ext := filepath.Ext(filename) + stem := filename[:len(filename)-len(ext)] + destPath := filepath.Join(base, filename) + for n := 2; ; n++ { + if _, statErr := os.Stat(destPath); statErr != nil { + break + } + filename = fmt.Sprintf("%s (%d)%s", stem, n, ext) + destPath = filepath.Join(base, filename) + } + + if err := c.SaveFile(fh, destPath); err != nil { + log.Printf("UploadTeamFile SaveFile error: %v (destPath=%s)", err, destPath) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"}) + } + + relPath := destPath[len("../data/"):] + + now := time.Now() + file := &models.File{ + ID: primitive.NewObjectID(), + TeamID: teamID, + Name: fh.Filename, + Type: "file", + SizeBytes: fh.Size, + StorageURL: relPath, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + parentID := c.FormValue("parent_id") + if parentID != "" { + if oid, err := primitive.ObjectIDFromHex(parentID); err == nil { + file.ParentID = &oid + } + } + + database.GetCollection("files").InsertOne(ctx, file) + return c.Status(fiber.StatusCreated).JSON(file) +} + +func ListUserFiles(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := bson.M{"user_id": userID} + if parentID := c.Query("parent_id"); parentID != "" { + oid, err := primitive.ObjectIDFromHex(parentID) + if err == nil { + filter["parent_id"] = oid + } + } else { + filter["parent_id"] = bson.M{"$exists": false} + } + + cursor, err := database.GetCollection("files").Find(ctx, filter, + options.Find().SetSort(bson.D{{Key: "type", Value: -1}, {Key: "name", Value: 1}})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch files"}) + } + defer cursor.Close(ctx) + + var files []models.File + cursor.All(ctx, &files) + if files == nil { + files = []models.File{} + } + return c.JSON(files) +} + +func CreateUserFolder(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + var body struct { + Name string `json:"name"` + ParentID string `json:"parent_id"` + } + if err := c.BodyParser(&body); err != nil || body.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name is required"}) + } + + now := time.Now() + file := &models.File{ + ID: primitive.NewObjectID(), + UserID: userID, + Name: body.Name, + Type: "folder", + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + if body.ParentID != "" { + if oid, err := primitive.ObjectIDFromHex(body.ParentID); err == nil { + file.ParentID = &oid + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + database.GetCollection("files").InsertOne(ctx, file) + return c.Status(fiber.StatusCreated).JSON(file) +} + +func UploadUserFile(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + fh, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"}) + } + + base := filepath.Join("../data/users", userID.Hex(), "files") + if err := os.MkdirAll(base, 0755); err != nil { + log.Printf("UploadUserFile MkdirAll error: %v (base=%s)", err, base) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"}) + } + + filename := fh.Filename + ext := filepath.Ext(filename) + stem := filename[:len(filename)-len(ext)] + destPath := filepath.Join(base, filename) + for n := 2; ; n++ { + if _, statErr := os.Stat(destPath); statErr != nil { + break + } + filename = fmt.Sprintf("%s (%d)%s", stem, n, ext) + destPath = filepath.Join(base, filename) + } + + if err := c.SaveFile(fh, destPath); err != nil { + log.Printf("UploadUserFile SaveFile error: %v (destPath=%s)", err, destPath) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"}) + } + + relPath := destPath[len("../data/"):] + + now := time.Now() + file := &models.File{ + ID: primitive.NewObjectID(), + UserID: userID, + Name: fh.Filename, + Type: "file", + SizeBytes: fh.Size, + StorageURL: relPath, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + parentID := c.FormValue("parent_id") + if parentID != "" { + if oid, err := primitive.ObjectIDFromHex(parentID); err == nil { + file.ParentID = &oid + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + database.GetCollection("files").InsertOne(ctx, file) + return c.Status(fiber.StatusCreated).JSON(file) +} + +func DownloadFile(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + fileID, err := primitive.ObjectIDFromHex(c.Params("fileId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid file ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var file models.File + if err := database.GetCollection("files").FindOne(ctx, bson.M{"_id": fileID}).Decode(&file); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "File not found"}) + } + + if file.TeamID != primitive.NilObjectID { + if _, err := getTeamRole(ctx, file.TeamID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + } else if file.UserID != primitive.NilObjectID { + if file.UserID != userID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + } else { + if _, err := getProjectRole(ctx, file.ProjectID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + } + + if file.Type == "folder" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cannot download a folder"}) + } + + diskPath := filepath.Join("../data", file.StorageURL) + if _, err := os.Stat(diskPath); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "File not found on disk"}) + } + + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Name)) + return c.SendFile(diskPath) +} + +func DeleteFile(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + fileID, err := primitive.ObjectIDFromHex(c.Params("fileId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid file ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var file models.File + if err := database.GetCollection("files").FindOne(ctx, bson.M{"_id": fileID}).Decode(&file); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "File not found"}) + } + + var roleFlags int + if file.TeamID != primitive.NilObjectID { + roleFlags, err = getTeamRole(ctx, file.TeamID, userID) + } else if file.UserID != primitive.NilObjectID { + if file.UserID == userID { + roleFlags = RoleOwner + } else { + err = fmt.Errorf("access denied") + } + } else { + roleFlags, err = getProjectRole(ctx, file.ProjectID, userID) + } + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("files").DeleteOne(ctx, bson.M{"_id": fileID}) + + if file.Type == "folder" { + database.GetCollection("files").DeleteMany(ctx, bson.M{"parent_id": fileID}) + } else if file.StorageURL != "" { + os.Remove(filepath.Join("../data", file.StorageURL)) + } + + return c.JSON(fiber.Map{"message": "Deleted"}) +} diff --git a/server/internal/handlers/notifications.go b/server/internal/handlers/notifications.go new file mode 100644 index 0000000..57217ec --- /dev/null +++ b/server/internal/handlers/notifications.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "context" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func createNotification(ctx context.Context, userID primitive.ObjectID, notifType, message string, projectID primitive.ObjectID, cardID primitive.ObjectID) { + n := &models.Notification{ + ID: primitive.NewObjectID(), + UserID: userID, + Type: notifType, + Message: message, + ProjectID: projectID, + CardID: cardID, + Read: false, + CreatedAt: time.Now(), + } + database.GetCollection("notifications").InsertOne(ctx, n) +} + +func ListNotifications(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := bson.M{"user_id": userID} + if c.Query("read") == "false" { + filter["read"] = false + } + + cursor, err := database.GetCollection("notifications").Find(ctx, filter, + options.Find().SetSort(bson.M{"created_at": -1})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch notifications"}) + } + defer cursor.Close(ctx) + + var notifications []models.Notification + cursor.All(ctx, ¬ifications) + if notifications == nil { + notifications = []models.Notification{} + } + return c.JSON(notifications) +} + +func MarkNotificationRead(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + notifID, err := primitive.ObjectIDFromHex(c.Params("notifId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid notification ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + database.GetCollection("notifications").UpdateOne(ctx, + bson.M{"_id": notifID, "user_id": userID}, + bson.M{"$set": bson.M{"read": true}}, + ) + return c.JSON(fiber.Map{"message": "Marked as read"}) +} + +func MarkAllNotificationsRead(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + database.GetCollection("notifications").UpdateMany(ctx, + bson.M{"user_id": userID, "read": false}, + bson.M{"$set": bson.M{"read": true}}, + ) + return c.JSON(fiber.Map{"message": "All notifications marked as read"}) +} + +func DeleteNotification(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + notifID, err := primitive.ObjectIDFromHex(c.Params("notifId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid notification ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + database.GetCollection("notifications").DeleteOne(ctx, bson.M{"_id": notifID, "user_id": userID}) + return c.JSON(fiber.Map{"message": "Notification deleted"}) +} diff --git a/server/internal/handlers/projects.go b/server/internal/handlers/projects.go new file mode 100644 index 0000000..0699cef --- /dev/null +++ b/server/internal/handlers/projects.go @@ -0,0 +1,633 @@ +package handlers + +import ( + "context" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func getProjectRole(ctx context.Context, projectID, userID primitive.ObjectID) (int, error) { + var pm models.ProjectMember + err := database.GetCollection("project_members").FindOne(ctx, bson.M{ + "project_id": projectID, + "user_id": userID, + }).Decode(&pm) + if err == nil { + return pm.RoleFlags, nil + } + + var project models.Project + if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil { + return 0, err + } + + if project.TeamID == primitive.NilObjectID { + return 0, fiber.ErrForbidden + } + + return getTeamRole(ctx, project.TeamID, userID) +} + +func ListProjects(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + type ProjectResponse struct { + ID primitive.ObjectID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + TeamID primitive.ObjectID `json:"team_id"` + TeamName string `json:"team_name"` + RoleFlags int `json:"role_flags"` + RoleName string `json:"role_name"` + IsPublic bool `json:"is_public"` + IsArchived bool `json:"is_archived"` + UpdatedAt time.Time `json:"updated_at"` + } + + result := []ProjectResponse{} + + cursor, err := database.GetCollection("team_members").Find(ctx, bson.M{"user_id": userID}) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch teams"}) + } + defer cursor.Close(ctx) + + var memberships []models.TeamMember + cursor.All(ctx, &memberships) + + for _, m := range memberships { + var team models.Team + database.GetCollection("teams").FindOne(ctx, bson.M{"_id": m.TeamID}).Decode(&team) + + projCursor, err := database.GetCollection("projects").Find(ctx, bson.M{"team_id": m.TeamID}) + if err != nil { + continue + } + var projects []models.Project + projCursor.All(ctx, &projects) + projCursor.Close(ctx) + + for _, p := range projects { + roleFlags := m.RoleFlags + var pm models.ProjectMember + if err := database.GetCollection("project_members").FindOne(ctx, bson.M{ + "project_id": p.ID, + "user_id": userID, + }).Decode(&pm); err == nil { + roleFlags = pm.RoleFlags + } + + result = append(result, ProjectResponse{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + TeamID: p.TeamID, + TeamName: team.Name, + RoleFlags: roleFlags, + RoleName: roleName(roleFlags), + IsPublic: p.IsPublic, + IsArchived: p.IsArchived, + UpdatedAt: p.UpdatedAt, + }) + } + } + + personalCursor, err := database.GetCollection("project_members").Find(ctx, bson.M{ + "user_id": userID, + }) + if err == nil { + defer personalCursor.Close(ctx) + var pms []models.ProjectMember + personalCursor.All(ctx, &pms) + for _, pm := range pms { + var p models.Project + if err := database.GetCollection("projects").FindOne(ctx, bson.M{ + "_id": pm.ProjectID, + "team_id": primitive.NilObjectID, + }).Decode(&p); err != nil { + continue + } + result = append(result, ProjectResponse{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + TeamID: p.TeamID, + TeamName: "Personal", + RoleFlags: pm.RoleFlags, + RoleName: roleName(pm.RoleFlags), + IsPublic: p.IsPublic, + IsArchived: p.IsArchived, + UpdatedAt: p.UpdatedAt, + }) + } + } + + return c.JSON(result) +} + +func CreatePersonalProject(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + var body struct { + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if body.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Project name is required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + now := time.Now() + project := &models.Project{ + ID: primitive.NewObjectID(), + TeamID: primitive.NilObjectID, + Name: body.Name, + Description: body.Description, + IsPublic: body.IsPublic, + IsArchived: false, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + if _, err := database.GetCollection("projects").InsertOne(ctx, project); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create project"}) + } + + member := &models.ProjectMember{ + ID: primitive.NewObjectID(), + ProjectID: project.ID, + UserID: userID, + RoleFlags: RoleOwner, + AddedAt: now, + } + database.GetCollection("project_members").InsertOne(ctx, member) + + defaultColumns := []string{"To Do", "In Progress", "Done"} + for i, title := range defaultColumns { + col := &models.BoardColumn{ + ID: primitive.NewObjectID(), + ProjectID: project.ID, + Title: title, + Position: i, + CreatedAt: now, + UpdatedAt: now, + } + database.GetCollection("board_columns").InsertOne(ctx, col) + } + + return c.Status(fiber.StatusCreated).JSON(project) +} + +func ListTeamProjects(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + teamRole, err := getTeamRole(ctx, teamID, userID) + if err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + cursor, err := database.GetCollection("projects").Find(ctx, bson.M{"team_id": teamID}, + options.Find().SetSort(bson.M{"updated_at": -1})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch projects"}) + } + defer cursor.Close(ctx) + + var projects []models.Project + cursor.All(ctx, &projects) + + type ProjectResponse struct { + models.Project + RoleFlags int `json:"role_flags"` + RoleName string `json:"role_name"` + } + + result := []ProjectResponse{} + for _, p := range projects { + flags := teamRole + var pm models.ProjectMember + if err := database.GetCollection("project_members").FindOne(ctx, bson.M{ + "project_id": p.ID, "user_id": userID, + }).Decode(&pm); err == nil { + flags = pm.RoleFlags + } + result = append(result, ProjectResponse{Project: p, RoleFlags: flags, RoleName: roleName(flags)}) + } + + return c.JSON(result) +} + +func CreateProject(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + var body struct { + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if body.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Project name is required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + now := time.Now() + project := &models.Project{ + ID: primitive.NewObjectID(), + TeamID: teamID, + Name: body.Name, + Description: body.Description, + IsPublic: body.IsPublic, + IsArchived: false, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + if _, err := database.GetCollection("projects").InsertOne(ctx, project); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create project"}) + } + + defaultColumns := []string{"To Do", "In Progress", "Done"} + for i, title := range defaultColumns { + col := &models.BoardColumn{ + ID: primitive.NewObjectID(), + ProjectID: project.ID, + Title: title, + Position: i, + CreatedAt: now, + UpdatedAt: now, + } + database.GetCollection("board_columns").InsertOne(ctx, col) + } + + return c.Status(fiber.StatusCreated).JSON(project) +} + +func GetProject(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + var project models.Project + if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Project not found"}) + } + + return c.JSON(fiber.Map{ + "id": project.ID, + "team_id": project.TeamID, + "name": project.Name, + "description": project.Description, + "visibility": project.Visibility, + "is_public": project.IsPublic, + "is_archived": project.IsArchived, + "role_flags": roleFlags, + "role_name": roleName(roleFlags), + "created_at": project.CreatedAt, + "updated_at": project.UpdatedAt, + }) +} + +func UpdateProject(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + var body struct { + Name string `json:"name"` + Description string `json:"description"` + IsPublic *bool `json:"is_public"` + Visibility string `json:"visibility"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + update := bson.M{"updated_at": time.Now()} + if body.Name != "" { + update["name"] = body.Name + } + if body.Description != "" { + update["description"] = body.Description + } + if body.IsPublic != nil { + update["is_public"] = *body.IsPublic + } + if body.Visibility != "" { + update["visibility"] = body.Visibility + update["is_public"] = body.Visibility == "public" + } + + col := database.GetCollection("projects") + col.UpdateOne(ctx, bson.M{"_id": projectID}, bson.M{"$set": update}) + + var project models.Project + col.FindOne(ctx, bson.M{"_id": projectID}).Decode(&project) + return c.JSON(project) +} + +func ArchiveProject(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + var project models.Project + if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Project not found"}) + } + + database.GetCollection("projects").UpdateOne(ctx, bson.M{"_id": projectID}, bson.M{"$set": bson.M{ + "is_archived": !project.IsArchived, + "updated_at": time.Now(), + }}) + + return c.JSON(fiber.Map{"is_archived": !project.IsArchived}) +} + +func DeleteProject(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleOwner) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Only owners can delete projects"}) + } + + database.GetCollection("projects").DeleteOne(ctx, bson.M{"_id": projectID}) + database.GetCollection("board_columns").DeleteMany(ctx, bson.M{"project_id": projectID}) + database.GetCollection("cards").DeleteMany(ctx, bson.M{"project_id": projectID}) + database.GetCollection("project_members").DeleteMany(ctx, bson.M{"project_id": projectID}) + database.GetCollection("events").DeleteMany(ctx, bson.M{"scope_id": projectID, "scope": "project"}) + database.GetCollection("files").DeleteMany(ctx, bson.M{"project_id": projectID}) + database.GetCollection("webhooks").DeleteMany(ctx, bson.M{"project_id": projectID}) + database.GetCollection("whiteboards").DeleteMany(ctx, bson.M{"project_id": projectID}) + + return c.JSON(fiber.Map{"message": "Project deleted"}) +} + +func ListProjectMembers(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getProjectRole(ctx, projectID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + cursor, err := database.GetCollection("project_members").Find(ctx, bson.M{"project_id": projectID}) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch members"}) + } + defer cursor.Close(ctx) + + var members []models.ProjectMember + cursor.All(ctx, &members) + + type MemberResponse struct { + UserID primitive.ObjectID `json:"user_id"` + Name string `json:"name"` + Email string `json:"email"` + RoleFlags int `json:"role_flags"` + RoleName string `json:"role_name"` + } + + result := []MemberResponse{} + for _, m := range members { + var user models.User + if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": m.UserID}).Decode(&user); err != nil { + continue + } + result = append(result, MemberResponse{ + UserID: m.UserID, + Name: user.Name, + Email: user.Email, + RoleFlags: m.RoleFlags, + RoleName: roleName(m.RoleFlags), + }) + } + return c.JSON(result) +} + +func AddProjectMember(c *fiber.Ctx) error { + requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + var body struct { + UserID string `json:"user_id"` + RoleFlags int `json:"role_flags"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + targetUserID, err := primitive.ObjectIDFromHex(body.UserID) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user_id"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, requesterID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + flags := body.RoleFlags + if flags == 0 { + flags = RoleViewer + } + + member := &models.ProjectMember{ + ID: primitive.NewObjectID(), + ProjectID: projectID, + UserID: targetUserID, + RoleFlags: flags, + AddedAt: time.Now(), + } + database.GetCollection("project_members").InsertOne(ctx, member) + return c.Status(fiber.StatusCreated).JSON(member) +} + +func UpdateProjectMemberRole(c *fiber.Ctx) error { + requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"}) + } + + var body struct { + RoleFlags int `json:"role_flags"` + } + c.BodyParser(&body) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, requesterID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("project_members").UpdateOne(ctx, + bson.M{"project_id": projectID, "user_id": targetUserID}, + bson.M{"$set": bson.M{"role_flags": body.RoleFlags}}, + ) + return c.JSON(fiber.Map{"user_id": targetUserID, "role_flags": body.RoleFlags, "role_name": roleName(body.RoleFlags)}) +} + +func RemoveProjectMember(c *fiber.Ctx) error { + requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, requesterID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("project_members").DeleteOne(ctx, bson.M{"project_id": projectID, "user_id": targetUserID}) + return c.JSON(fiber.Map{"message": "Member removed"}) +} diff --git a/server/internal/handlers/teams.go b/server/internal/handlers/teams.go new file mode 100644 index 0000000..7c34769 --- /dev/null +++ b/server/internal/handlers/teams.go @@ -0,0 +1,602 @@ +package handlers + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + RoleViewer = 1 + RoleEditor = 2 + RoleAdmin = 4 + RoleOwner = 8 +) + +func hasPermission(userRole, requiredRole int) bool { + return userRole >= requiredRole +} + +func roleName(flags int) string { + switch { + case flags&RoleOwner != 0: + return "Owner" + case flags&RoleAdmin != 0: + return "Admin" + case flags&RoleEditor != 0: + return "Editor" + default: + return "Viewer" + } +} + +func getTeamRole(ctx context.Context, teamID, userID primitive.ObjectID) (int, error) { + var member models.TeamMember + err := database.GetCollection("team_members").FindOne(ctx, bson.M{ + "team_id": teamID, + "user_id": userID, + }).Decode(&member) + if err != nil { + return 0, err + } + return member.RoleFlags, nil +} + +func ListTeams(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cursor, err := database.GetCollection("team_members").Find(ctx, bson.M{"user_id": userID}) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch teams"}) + } + defer cursor.Close(ctx) + + var memberships []models.TeamMember + if err := cursor.All(ctx, &memberships); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to decode memberships"}) + } + + type TeamResponse struct { + ID primitive.ObjectID `json:"id"` + Name string `json:"name"` + WorkspaceID string `json:"workspace_id"` + MemberCount int64 `json:"member_count"` + RoleFlags int `json:"role_flags"` + RoleName string `json:"role_name"` + CreatedAt time.Time `json:"created_at"` + } + + result := []TeamResponse{} + for _, m := range memberships { + var team models.Team + if err := database.GetCollection("teams").FindOne(ctx, bson.M{"_id": m.TeamID}).Decode(&team); err != nil { + continue + } + count, _ := database.GetCollection("team_members").CountDocuments(ctx, bson.M{"team_id": m.TeamID}) + result = append(result, TeamResponse{ + ID: team.ID, + Name: team.Name, + WorkspaceID: team.WorkspaceID, + MemberCount: count, + RoleFlags: m.RoleFlags, + RoleName: roleName(m.RoleFlags), + CreatedAt: team.CreatedAt, + }) + } + + return c.JSON(result) +} + +func CreateTeam(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + var body struct { + Name string `json:"name"` + WorkspaceID string `json:"workspace_id"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if body.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Team name is required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + now := time.Now() + team := &models.Team{ + ID: primitive.NewObjectID(), + Name: body.Name, + WorkspaceID: body.WorkspaceID, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + if _, err := database.GetCollection("teams").InsertOne(ctx, team); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create team"}) + } + + member := &models.TeamMember{ + ID: primitive.NewObjectID(), + TeamID: team.ID, + UserID: userID, + RoleFlags: RoleOwner, + InvitedBy: userID, + JoinedAt: now, + } + if _, err := database.GetCollection("team_members").InsertOne(ctx, member); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to add team owner"}) + } + + return c.Status(fiber.StatusCreated).JSON(team) +} + +func GetTeam(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, userID) + if err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + var team models.Team + if err := database.GetCollection("teams").FindOne(ctx, bson.M{"_id": teamID}).Decode(&team); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Team not found"}) + } + + count, _ := database.GetCollection("team_members").CountDocuments(ctx, bson.M{"team_id": teamID}) + + return c.JSON(fiber.Map{ + "id": team.ID, + "name": team.Name, + "workspace_id": team.WorkspaceID, + "avatar_url": team.AvatarURL, + "banner_url": team.BannerURL, + "member_count": count, + "role_flags": roleFlags, + "role_name": roleName(roleFlags), + "created_at": team.CreatedAt, + "updated_at": team.UpdatedAt, + }) +} + +func UpdateTeam(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + var body struct { + Name string `json:"name"` + WorkspaceID string `json:"workspace_id"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + update := bson.M{"updated_at": time.Now()} + if body.Name != "" { + update["name"] = body.Name + } + if body.WorkspaceID != "" { + update["workspace_id"] = body.WorkspaceID + } + + col := database.GetCollection("teams") + if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": update}); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update team"}) + } + + var team models.Team + col.FindOne(ctx, bson.M{"_id": teamID}).Decode(&team) + return c.JSON(team) +} + +func DeleteTeam(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, userID) + if err != nil || !hasPermission(roleFlags, RoleOwner) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Only owners can delete teams"}) + } + + database.GetCollection("teams").DeleteOne(ctx, bson.M{"_id": teamID}) + database.GetCollection("team_members").DeleteMany(ctx, bson.M{"team_id": teamID}) + + return c.JSON(fiber.Map{"message": "Team deleted"}) +} + +func ListTeamMembers(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getTeamRole(ctx, teamID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + cursor, err := database.GetCollection("team_members").Find(ctx, bson.M{"team_id": teamID}, + options.Find().SetSort(bson.M{"joined_at": 1})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch members"}) + } + defer cursor.Close(ctx) + + var memberships []models.TeamMember + cursor.All(ctx, &memberships) + + type MemberResponse struct { + ID primitive.ObjectID `json:"id"` + UserID primitive.ObjectID `json:"user_id"` + Name string `json:"name"` + Email string `json:"email"` + RoleFlags int `json:"role_flags"` + RoleName string `json:"role_name"` + JoinedAt time.Time `json:"joined_at"` + } + + result := []MemberResponse{} + for _, m := range memberships { + var user models.User + if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": m.UserID}).Decode(&user); err != nil { + continue + } + result = append(result, MemberResponse{ + ID: m.ID, + UserID: m.UserID, + Name: user.Name, + Email: user.Email, + RoleFlags: m.RoleFlags, + RoleName: roleName(m.RoleFlags), + JoinedAt: m.JoinedAt, + }) + } + + return c.JSON(result) +} + +func InviteTeamMember(c *fiber.Ctx) error { + inviterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + var body struct { + Email string `json:"email"` + RoleFlags int `json:"role_flags"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if body.Email == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email is required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, inviterID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + var invitee models.User + if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": body.Email}).Decode(&invitee); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User with that email not found"}) + } + + existing := database.GetCollection("team_members").FindOne(ctx, bson.M{"team_id": teamID, "user_id": invitee.ID}) + if existing.Err() == nil { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User is already a member"}) + } + + flags := body.RoleFlags + if flags == 0 { + flags = RoleViewer + } + + member := &models.TeamMember{ + ID: primitive.NewObjectID(), + TeamID: teamID, + UserID: invitee.ID, + RoleFlags: flags, + InvitedBy: inviterID, + JoinedAt: time.Now(), + } + if _, err := database.GetCollection("team_members").InsertOne(ctx, member); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to add member"}) + } + + var team models.Team + if err := database.GetCollection("teams").FindOne(ctx, bson.M{"_id": teamID}).Decode(&team); err == nil { + createNotification(ctx, invitee.ID, "team_invite", + "You have been invited to team \""+team.Name+"\"", + primitive.NilObjectID, primitive.NilObjectID) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "message": "Member added successfully", + "member": fiber.Map{ + "user_id": invitee.ID, + "name": invitee.Name, + "email": invitee.Email, + "role_flags": flags, + "role_name": roleName(flags), + }, + }) +} + +func UpdateTeamMemberRole(c *fiber.Ctx) error { + requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"}) + } + + var body struct { + RoleFlags int `json:"role_flags"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, requesterID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + if _, err := database.GetCollection("team_members").UpdateOne(ctx, + bson.M{"team_id": teamID, "user_id": targetUserID}, + bson.M{"$set": bson.M{"role_flags": body.RoleFlags}}, + ); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update role"}) + } + + return c.JSON(fiber.Map{ + "user_id": targetUserID, + "role_flags": body.RoleFlags, + "role_name": roleName(body.RoleFlags), + }) +} + +func RemoveTeamMember(c *fiber.Ctx) error { + requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, requesterID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("team_members").DeleteOne(ctx, bson.M{"team_id": teamID, "user_id": targetUserID}) + return c.JSON(fiber.Map{"message": "Member removed"}) +} + +var allowedImageExts = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".webp": true, +} + +func uploadTeamImage(c *fiber.Ctx, imageType string) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + roleFlags, err := getTeamRole(ctx, teamID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + fh, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"}) + } + + ext := strings.ToLower(filepath.Ext(fh.Filename)) + if !allowedImageExts[ext] { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid image type"}) + } + + dir := filepath.Join("../data/teams", teamID.Hex()) + if err := os.MkdirAll(dir, 0755); err != nil { + log.Printf("uploadTeamImage MkdirAll error: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"}) + } + + existingGlob := filepath.Join(dir, imageType+".*") + if matches, _ := filepath.Glob(existingGlob); len(matches) > 0 { + for _, m := range matches { + os.Remove(m) + } + } + + destPath := filepath.Join(dir, imageType+ext) + if err := c.SaveFile(fh, destPath); err != nil { + log.Printf("uploadTeamImage SaveFile error: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save image"}) + } + + imageURL := fmt.Sprintf("/api/teams/%s/%s", teamID.Hex(), imageType) + field := imageType + "_url" + + col := database.GetCollection("teams") + if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": bson.M{ + field: imageURL, + "updated_at": time.Now(), + }}); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update team"}) + } + + var team models.Team + col.FindOne(ctx, bson.M{"_id": teamID}).Decode(&team) + + count, _ := database.GetCollection("team_members").CountDocuments(ctx, bson.M{"team_id": teamID}) + + return c.JSON(fiber.Map{ + "id": team.ID, + "name": team.Name, + "workspace_id": team.WorkspaceID, + "avatar_url": team.AvatarURL, + "banner_url": team.BannerURL, + "member_count": count, + "role_flags": roleFlags, + "role_name": roleName(roleFlags), + "created_at": team.CreatedAt, + "updated_at": team.UpdatedAt, + }) +} + +func UploadTeamAvatar(c *fiber.Ctx) error { + return uploadTeamImage(c, "avatar") +} + +func UploadTeamBanner(c *fiber.Ctx) error { + return uploadTeamImage(c, "banner") +} + +func serveTeamImage(c *fiber.Ctx, imageType string) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + teamID, err := primitive.ObjectIDFromHex(c.Params("teamId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getTeamRole(ctx, teamID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + dir := filepath.Join("../data/teams", teamID.Hex()) + for ext := range allowedImageExts { + p := filepath.Join(dir, imageType+ext) + if _, err := os.Stat(p); err == nil { + return c.SendFile(p) + } + } + + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Image not found"}) +} + +func ServeTeamAvatar(c *fiber.Ctx) error { + return serveTeamImage(c, "avatar") +} + +func ServeTeamBanner(c *fiber.Ctx) error { + return serveTeamImage(c, "banner") +} diff --git a/server/internal/handlers/users.go b/server/internal/handlers/users.go new file mode 100644 index 0000000..ff8b3ef --- /dev/null +++ b/server/internal/handlers/users.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "context" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" + "golang.org/x/crypto/bcrypt" +) + +func GetMe(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var user models.User + if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) + } + + return c.JSON(user) +} + +func UpdateMe(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + var body struct { + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + update := bson.M{"updated_at": time.Now()} + if body.Name != "" { + update["name"] = body.Name + } + if body.Email != "" { + update["email"] = body.Email + } + if body.AvatarURL != "" { + update["avatar_url"] = body.AvatarURL + } + + col := database.GetCollection("users") + if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": update}); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update user"}) + } + + var user models.User + if err := col.FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch updated user"}) + } + + return c.JSON(user) +} + +func SearchUsers(c *fiber.Ctx) error { + q := c.Query("q") + if len(q) < 1 { + return c.JSON([]fiber.Map{}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{ + "$or": bson.A{ + bson.M{"name": bson.M{"$regex": q, "$options": "i"}}, + bson.M{"email": bson.M{"$regex": q, "$options": "i"}}, + }, + } + + cursor, err := database.GetCollection("users").Find(ctx, filter, options.Find().SetLimit(10)) + if err != nil { + return c.JSON([]fiber.Map{}) + } + defer cursor.Close(ctx) + + var users []models.User + cursor.All(ctx, &users) + + type UserResult struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } + result := []UserResult{} + for _, u := range users { + result = append(result, UserResult{ID: u.ID.Hex(), Name: u.Name, Email: u.Email}) + } + return c.JSON(result) +} + +func UploadUserAvatar(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + fh, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"}) + } + + ext := strings.ToLower(filepath.Ext(fh.Filename)) + if !allowedImageExts[ext] { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid image type"}) + } + + dir := filepath.Join("../data/users", userID.Hex()) + if err := os.MkdirAll(dir, 0755); err != nil { + log.Printf("UploadUserAvatar MkdirAll error: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"}) + } + + existingGlob := filepath.Join(dir, "avatar.*") + if matches, _ := filepath.Glob(existingGlob); len(matches) > 0 { + for _, m := range matches { + os.Remove(m) + } + } + + destPath := filepath.Join(dir, "avatar"+ext) + if err := c.SaveFile(fh, destPath); err != nil { + log.Printf("UploadUserAvatar SaveFile error: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save image"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + col := database.GetCollection("users") + if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{ + "avatar_url": "/api/users/me/avatar", + "updated_at": time.Now(), + }}); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update user"}) + } + + var user models.User + if err := col.FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch updated user"}) + } + + return c.JSON(user) +} + +func ServeUserAvatar(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + dir := filepath.Join("../data/users", userID.Hex()) + for ext := range allowedImageExts { + p := filepath.Join(dir, "avatar"+ext) + if _, err := os.Stat(p); err == nil { + return c.SendFile(p) + } + } + + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Avatar not found"}) +} + +func ChangePassword(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + var body struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + if body.CurrentPassword == "" || body.NewPassword == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "current_password and new_password are required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + col := database.GetCollection("users") + var user models.User + if err := col.FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.CurrentPassword)); err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Current password is incorrect"}) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"}) + } + + if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{ + "password_hash": string(hash), + "updated_at": time.Now(), + }}); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"}) + } + + return c.JSON(fiber.Map{"message": "Password updated successfully"}) +} diff --git a/server/internal/handlers/webhooks.go b/server/internal/handlers/webhooks.go new file mode 100644 index 0000000..cc2ce04 --- /dev/null +++ b/server/internal/handlers/webhooks.go @@ -0,0 +1,220 @@ +package handlers + +import ( + "context" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func ListWebhooks(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getProjectRole(ctx, projectID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + cursor, err := database.GetCollection("webhooks").Find(ctx, + bson.M{"project_id": projectID}, + options.Find().SetSort(bson.M{"created_at": -1})) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch webhooks"}) + } + defer cursor.Close(ctx) + + var webhooks []models.Webhook + cursor.All(ctx, &webhooks) + if webhooks == nil { + webhooks = []models.Webhook{} + } + return c.JSON(webhooks) +} + +func CreateWebhook(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + var body struct { + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Secret string `json:"secret"` + } + if err := c.BodyParser(&body); err != nil || body.Name == "" || body.URL == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name and url are required"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + wType := body.Type + if wType == "" { + wType = "custom" + } + + now := time.Now() + webhook := &models.Webhook{ + ID: primitive.NewObjectID(), + ProjectID: projectID, + Name: body.Name, + Type: wType, + URL: body.URL, + Status: "active", + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + + if body.Secret != "" { + webhook.SecretHash = body.Secret + } + + database.GetCollection("webhooks").InsertOne(ctx, webhook) + return c.Status(fiber.StatusCreated).JSON(webhook) +} + +func UpdateWebhook(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + webhookID, err := primitive.ObjectIDFromHex(c.Params("webhookId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid webhook ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var wh models.Webhook + if err := database.GetCollection("webhooks").FindOne(ctx, bson.M{"_id": webhookID}).Decode(&wh); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Webhook not found"}) + } + + roleFlags, err := getProjectRole(ctx, wh.ProjectID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + var body struct { + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + } + c.BodyParser(&body) + + update := bson.M{"updated_at": time.Now()} + if body.Name != "" { + update["name"] = body.Name + } + if body.URL != "" { + update["url"] = body.URL + } + if body.Type != "" { + update["type"] = body.Type + } + + col := database.GetCollection("webhooks") + col.UpdateOne(ctx, bson.M{"_id": webhookID}, bson.M{"$set": update}) + + var updated models.Webhook + col.FindOne(ctx, bson.M{"_id": webhookID}).Decode(&updated) + return c.JSON(updated) +} + +func ToggleWebhook(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + webhookID, err := primitive.ObjectIDFromHex(c.Params("webhookId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid webhook ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var wh models.Webhook + if err := database.GetCollection("webhooks").FindOne(ctx, bson.M{"_id": webhookID}).Decode(&wh); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Webhook not found"}) + } + + roleFlags, err := getProjectRole(ctx, wh.ProjectID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + newStatus := "active" + if wh.Status == "active" { + newStatus = "inactive" + } + + col := database.GetCollection("webhooks") + col.UpdateOne(ctx, bson.M{"_id": webhookID}, bson.M{"$set": bson.M{ + "status": newStatus, + "updated_at": time.Now(), + }}) + + var updated models.Webhook + col.FindOne(ctx, bson.M{"_id": webhookID}).Decode(&updated) + return c.JSON(updated) +} + +func DeleteWebhook(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + webhookID, err := primitive.ObjectIDFromHex(c.Params("webhookId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid webhook ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var wh models.Webhook + if err := database.GetCollection("webhooks").FindOne(ctx, bson.M{"_id": webhookID}).Decode(&wh); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Webhook not found"}) + } + + roleFlags, err := getProjectRole(ctx, wh.ProjectID, userID) + if err != nil || !hasPermission(roleFlags, RoleAdmin) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + database.GetCollection("webhooks").DeleteOne(ctx, bson.M{"_id": webhookID}) + return c.JSON(fiber.Map{"message": "Webhook deleted"}) +} diff --git a/server/internal/handlers/whiteboard.go b/server/internal/handlers/whiteboard.go new file mode 100644 index 0000000..24029f4 --- /dev/null +++ b/server/internal/handlers/whiteboard.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "context" + "time" + + "github.com/fpmb/server/internal/database" + "github.com/fpmb/server/internal/models" + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" +) + +func GetWhiteboard(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := getProjectRole(ctx, projectID, userID); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"}) + } + + var wb models.Whiteboard + err = database.GetCollection("whiteboards").FindOne(ctx, bson.M{"project_id": projectID}).Decode(&wb) + if err == mongo.ErrNoDocuments { + return c.JSON(fiber.Map{"id": nil, "project_id": projectID, "data": "", "updated_at": nil}) + } + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch whiteboard"}) + } + + return c.JSON(wb) +} + +func SaveWhiteboard(c *fiber.Ctx) error { + userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"}) + } + + projectID, err := primitive.ObjectIDFromHex(c.Params("projectId")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"}) + } + + var body struct { + Data string `json:"data"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + roleFlags, err := getProjectRole(ctx, projectID, userID) + if err != nil || !hasPermission(roleFlags, RoleEditor) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"}) + } + + now := time.Now() + col := database.GetCollection("whiteboards") + + var existing models.Whiteboard + err = col.FindOne(ctx, bson.M{"project_id": projectID}).Decode(&existing) + + if err == mongo.ErrNoDocuments { + wb := &models.Whiteboard{ + ID: primitive.NewObjectID(), + ProjectID: projectID, + Data: body.Data, + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + col.InsertOne(ctx, wb) + return c.JSON(fiber.Map{"id": wb.ID, "project_id": projectID, "updated_at": now}) + } + + col.UpdateOne(ctx, bson.M{"project_id": projectID}, bson.M{"$set": bson.M{ + "data": body.Data, + "updated_at": now, + }}) + + return c.JSON(fiber.Map{"id": existing.ID, "project_id": projectID, "updated_at": now}) +} diff --git a/server/internal/handlers/ws.go b/server/internal/handlers/ws.go new file mode 100644 index 0000000..962273c --- /dev/null +++ b/server/internal/handlers/ws.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "encoding/json" + "log" + "os" + "sync" + + "github.com/gofiber/websocket/v2" + "github.com/golang-jwt/jwt/v5" +) + +type wsMessage struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload,omitempty"` + UserID string `json:"user_id,omitempty"` + Name string `json:"name,omitempty"` + X float64 `json:"x,omitempty"` + Y float64 `json:"y,omitempty"` +} + +type wsClient struct { + conn *websocket.Conn + userID string + name string +} + +type whiteboardRoom struct { + clients map[*websocket.Conn]*wsClient + mu sync.RWMutex +} + +var wsRooms = struct { + m map[string]*whiteboardRoom + mu sync.RWMutex +}{m: make(map[string]*whiteboardRoom)} + +func getRoom(boardID string) *whiteboardRoom { + wsRooms.mu.Lock() + defer wsRooms.mu.Unlock() + if room, ok := wsRooms.m[boardID]; ok { + return room + } + room := &whiteboardRoom{clients: make(map[*websocket.Conn]*wsClient)} + wsRooms.m[boardID] = room + return room +} + +func (r *whiteboardRoom) broadcast(sender *websocket.Conn, msg []byte) { + r.mu.RLock() + defer r.mu.RUnlock() + for conn := range r.clients { + if conn != sender { + _ = conn.WriteMessage(websocket.TextMessage, msg) + } + } +} + +func (r *whiteboardRoom) userList() []map[string]string { + r.mu.RLock() + defer r.mu.RUnlock() + list := make([]map[string]string, 0, len(r.clients)) + for _, c := range r.clients { + list = append(list, map[string]string{"user_id": c.userID, "name": c.name}) + } + return list +} + +func parseWSToken(tokenStr string) (userID string, email string, ok bool) { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + secret = "changeme-jwt-secret" + } + + type claims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims + } + c := &claims{} + token, err := jwt.ParseWithClaims(tokenStr, c, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(secret), nil + }) + if err != nil || !token.Valid { + return "", "", false + } + return c.UserID, c.Email, true +} + +func WhiteboardWS(c *websocket.Conn) { + boardID := c.Params("id") + tokenStr := c.Query("token", "") + userName := c.Query("name", "Anonymous") + + userID, _, ok := parseWSToken(tokenStr) + if !ok { + _ = c.WriteJSON(map[string]string{"type": "error", "payload": "unauthorized"}) + _ = c.Close() + return + } + + room := getRoom(boardID) + + client := &wsClient{conn: c, userID: userID, name: userName} + room.mu.Lock() + room.clients[c] = client + room.mu.Unlock() + + joinMsg, _ := json.Marshal(map[string]interface{}{ + "type": "join", + "user_id": userID, + "name": userName, + "users": room.userList(), + }) + room.broadcast(nil, joinMsg) + + selfMsg, _ := json.Marshal(map[string]interface{}{ + "type": "users", + "users": room.userList(), + }) + _ = c.WriteMessage(websocket.TextMessage, selfMsg) + + defer func() { + room.mu.Lock() + delete(room.clients, c) + empty := len(room.clients) == 0 + room.mu.Unlock() + + leaveMsg, _ := json.Marshal(map[string]interface{}{ + "type": "leave", + "user_id": userID, + "name": userName, + "users": room.userList(), + }) + room.broadcast(nil, leaveMsg) + + if empty { + wsRooms.mu.Lock() + delete(wsRooms.m, boardID) + wsRooms.mu.Unlock() + } + }() + + for { + _, msg, err := c.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { + log.Printf("WS error board=%s user=%s: %v", boardID, userID, err) + } + break + } + + var incoming map[string]interface{} + if json.Unmarshal(msg, &incoming) == nil { + incoming["user_id"] = userID + incoming["name"] = userName + outMsg, _ := json.Marshal(incoming) + room.broadcast(c, outMsg) + } + } +} diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go new file mode 100644 index 0000000..88bf87b --- /dev/null +++ b/server/internal/middleware/auth.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "os" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +type JWTClaims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +func Protected() fiber.Handler { + return func(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + if authHeader == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization header"}) + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid authorization header format"}) + } + + tokenStr := parts[1] + secret := os.Getenv("JWT_SECRET") + if secret == "" { + secret = "changeme-jwt-secret" + } + + claims := &JWTClaims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fiber.ErrUnauthorized + } + return []byte(secret), nil + }) + + if err != nil || !token.Valid { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"}) + } + + c.Locals("user_id", claims.UserID) + c.Locals("user_email", claims.Email) + return c.Next() + } +} diff --git a/server/internal/models/models.go b/server/internal/models/models.go new file mode 100644 index 0000000..bf55cfc --- /dev/null +++ b/server/internal/models/models.go @@ -0,0 +1,184 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type User struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + Name string `bson:"name" json:"name"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash" json:"-"` + AvatarURL string `bson:"avatar_url,omitempty" json:"avatar_url,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type Team struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + Name string `bson:"name" json:"name"` + WorkspaceID string `bson:"workspace_id" json:"workspace_id"` + AvatarURL string `bson:"avatar_url,omitempty" json:"avatar_url,omitempty"` + BannerURL string `bson:"banner_url,omitempty" json:"banner_url,omitempty"` + CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type TeamMember struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + TeamID primitive.ObjectID `bson:"team_id" json:"team_id"` + UserID primitive.ObjectID `bson:"user_id" json:"user_id"` + RoleFlags int `bson:"role_flags" json:"role_flags"` + InvitedBy primitive.ObjectID `bson:"invited_by" json:"invited_by"` + JoinedAt time.Time `bson:"joined_at" json:"joined_at"` +} + +type Project struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + TeamID primitive.ObjectID `bson:"team_id" json:"team_id"` + Name string `bson:"name" json:"name"` + Description string `bson:"description" json:"description"` + Visibility string `bson:"visibility" json:"visibility"` + IsPublic bool `bson:"is_public" json:"is_public"` + IsArchived bool `bson:"is_archived" json:"is_archived"` + CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type ProjectMember struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"` + UserID primitive.ObjectID `bson:"user_id" json:"user_id"` + RoleFlags int `bson:"role_flags" json:"role_flags"` + AddedAt time.Time `bson:"added_at" json:"added_at"` +} + +type BoardColumn struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"` + Title string `bson:"title" json:"title"` + Position int `bson:"position" json:"position"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type Subtask struct { + ID int `bson:"id" json:"id"` + Text string `bson:"text" json:"text"` + Done bool `bson:"done" json:"done"` +} + +type Card struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + ColumnID primitive.ObjectID `bson:"column_id" json:"column_id"` + ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"` + Title string `bson:"title" json:"title"` + Description string `bson:"description" json:"description"` + Priority string `bson:"priority" json:"priority"` + Color string `bson:"color" json:"color"` + DueDate *time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"` + Assignees []string `bson:"assignees" json:"assignees"` + Subtasks []Subtask `bson:"subtasks" json:"subtasks"` + Position int `bson:"position" json:"position"` + CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type Event struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + Title string `bson:"title" json:"title"` + Date string `bson:"date" json:"date"` + Time string `bson:"time" json:"time"` + Color string `bson:"color" json:"color"` + Description string `bson:"description" json:"description"` + Scope string `bson:"scope" json:"scope"` + ScopeID primitive.ObjectID `bson:"scope_id" json:"scope_id"` + CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type Notification struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + UserID primitive.ObjectID `bson:"user_id" json:"user_id"` + Type string `bson:"type" json:"type"` + Message string `bson:"message" json:"message"` + ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"` + CardID primitive.ObjectID `bson:"card_id,omitempty" json:"card_id,omitempty"` + Read bool `bson:"read" json:"read"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +type Doc struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + TeamID primitive.ObjectID `bson:"team_id" json:"team_id"` + Title string `bson:"title" json:"title"` + Content string `bson:"content" json:"content"` + CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type File struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + ProjectID primitive.ObjectID `bson:"project_id,omitempty" json:"project_id,omitempty"` + TeamID primitive.ObjectID `bson:"team_id,omitempty" json:"team_id,omitempty"` + UserID primitive.ObjectID `bson:"user_id,omitempty" json:"user_id,omitempty"` + Name string `bson:"name" json:"name"` + Type string `bson:"type" json:"type"` + SizeBytes int64 `bson:"size_bytes" json:"size_bytes"` + ParentID *primitive.ObjectID `bson:"parent_id,omitempty" json:"parent_id,omitempty"` + StorageURL string `bson:"storage_url,omitempty" json:"storage_url,omitempty"` + CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type Webhook struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"` + Name string `bson:"name" json:"name"` + Type string `bson:"type" json:"type"` + URL string `bson:"url" json:"url"` + SecretHash string `bson:"secret_hash,omitempty" json:"-"` + Status string `bson:"status" json:"status"` + LastTriggered *time.Time `bson:"last_triggered,omitempty" json:"last_triggered,omitempty"` + CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type Whiteboard struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"` + Data string `bson:"data" json:"data"` + CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type APIKey struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + UserID primitive.ObjectID `bson:"user_id" json:"user_id"` + Name string `bson:"name" json:"name"` + Scopes []string `bson:"scopes" json:"scopes"` + KeyHash string `bson:"key_hash" json:"-"` + Prefix string `bson:"prefix" json:"prefix"` + LastUsed *time.Time `bson:"last_used,omitempty" json:"last_used,omitempty"` + RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +type ChatMessage struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + TeamID primitive.ObjectID `bson:"team_id" json:"team_id"` + UserID primitive.ObjectID `bson:"user_id" json:"user_id"` + UserName string `bson:"user_name" json:"user_name"` + Content string `bson:"content" json:"content"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..f273cc5 --- /dev/null +++ b/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 0000000..f99f4e3 --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,99 @@ +const BASE = '/api'; + +let accessToken: string | null = + typeof localStorage !== 'undefined' ? localStorage.getItem('access_token') : null; + +export function getAccessToken() { + return accessToken; +} + +export function setAccessToken(token: string | null) { + accessToken = token; + if (typeof localStorage !== 'undefined') { + if (token) { + localStorage.setItem('access_token', token); + } else { + localStorage.removeItem('access_token'); + } + } +} + +async function refreshAccessToken(): Promise { + const refreshToken = + typeof localStorage !== 'undefined' ? localStorage.getItem('refresh_token') : null; + if (!refreshToken) return null; + + const res = await fetch(`${BASE}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (!res.ok) { + setAccessToken(null); + if (typeof localStorage !== 'undefined') localStorage.removeItem('refresh_token'); + return null; + } + + const data = await res.json(); + setAccessToken(data.access_token); + if (typeof localStorage !== 'undefined') { + localStorage.setItem('refresh_token', data.refresh_token); + } + return data.access_token; +} + +export async function apiFetch( + path: string, + options: RequestInit = {}, + retry = true +): Promise { + const token = accessToken; + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record) + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${BASE}${path}`, { ...options, headers }); + + if (res.status === 401 && retry) { + const newToken = await refreshAccessToken(); + if (newToken) return apiFetch(path, options, false); + throw new Error('Unauthorized'); + } + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} + +export async function apiFetchFormData( + path: string, + formData: FormData, + retry = true +): Promise { + const token = accessToken; + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${BASE}${path}`, { method: 'POST', body: formData, headers }); + + if (res.status === 401 && retry) { + const newToken = await refreshAccessToken(); + if (newToken) return apiFetchFormData(path, formData, false); + throw new Error('Unauthorized'); + } + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..023575c --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,371 @@ +import { apiFetch, apiFetchFormData } from './client'; +import type { + AuthResponse, + User, + Team, + TeamMember, + Project, + ProjectMember, + BoardData, + Column, + Card, + Event, + Notification, + Doc, + FileItem, + Webhook, + Whiteboard, + ApiKey, + ApiKeyCreated, + ChatMessage +} from '$lib/types/api'; + +export const auth = { + register: (name: string, email: string, password: string) => + apiFetch('/auth/register', { + method: 'POST', + body: JSON.stringify({ name, email, password }) + }), + + login: (email: string, password: string) => + apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }), + + logout: () => apiFetch('/auth/logout', { method: 'POST' }) +}; + +export const users = { + me: () => apiFetch('/users/me'), + + updateMe: (data: Partial>) => + apiFetch('/users/me', { method: 'PUT', body: JSON.stringify(data) }), + + changePassword: (current_password: string, new_password: string) => + apiFetch('/users/me/password', { + method: 'PUT', + body: JSON.stringify({ current_password, new_password }) + }), + + search: (q: string) => + apiFetch<{ id: string; name: string; email: string }[]>(`/users/search?q=${encodeURIComponent(q)}`), + + listFiles: (parentId = '') => { + const qs = parentId ? `?parent_id=${encodeURIComponent(parentId)}` : ''; + return apiFetch(`/users/me/files${qs}`); + }, + + createFolder: (name: string, parent_id = '') => + apiFetch('/users/me/files/folder', { + method: 'POST', + body: JSON.stringify({ name, parent_id }) + }), + + uploadFile: (file: File, parent_id = '') => { + const fd = new FormData(); + fd.append('file', file); + if (parent_id) fd.append('parent_id', parent_id); + return apiFetchFormData('/users/me/files/upload', fd); + }, + + uploadAvatar: (file: File) => { + const fd = new FormData(); + fd.append('file', file); + return apiFetchFormData('/users/me/avatar', fd); + } +}; + +export const teams = { + list: () => apiFetch('/teams'), + + create: (name: string) => + apiFetch('/teams', { method: 'POST', body: JSON.stringify({ name }) }), + + get: (teamId: string) => apiFetch(`/teams/${teamId}`), + + update: (teamId: string, data: Partial>) => + apiFetch(`/teams/${teamId}`, { method: 'PUT', body: JSON.stringify(data) }), + + delete: (teamId: string) => apiFetch(`/teams/${teamId}`, { method: 'DELETE' }), + + listMembers: (teamId: string) => apiFetch(`/teams/${teamId}/members`), + + invite: (teamId: string, email: string, role_flags: number) => + apiFetch<{ message: string; member: TeamMember }>(`/teams/${teamId}/members/invite`, { + method: 'POST', + body: JSON.stringify({ email, role_flags }) + }), + + updateMemberRole: (teamId: string, userId: string, role_flags: number) => + apiFetch<{ user_id: string; role_flags: number; role_name: string }>( + `/teams/${teamId}/members/${userId}`, + { method: 'PUT', body: JSON.stringify({ role_flags }) } + ), + + removeMember: (teamId: string, userId: string) => + apiFetch(`/teams/${teamId}/members/${userId}`, { method: 'DELETE' }), + + listProjects: (teamId: string) => apiFetch(`/teams/${teamId}/projects`), + + createProject: (teamId: string, name: string, description: string) => + apiFetch(`/teams/${teamId}/projects`, { + method: 'POST', + body: JSON.stringify({ name, description }) + }), + + listEvents: (teamId: string) => apiFetch(`/teams/${teamId}/events`), + + createEvent: ( + teamId: string, + data: Pick + ) => + apiFetch(`/teams/${teamId}/events`, { method: 'POST', body: JSON.stringify(data) }), + + listDocs: (teamId: string) => apiFetch(`/teams/${teamId}/docs`), + + createDoc: (teamId: string, title: string, content: string) => + apiFetch(`/teams/${teamId}/docs`, { + method: 'POST', + body: JSON.stringify({ title, content }) + }), + + listFiles: (teamId: string, parentId = '') => { + const qs = parentId ? `?parent_id=${encodeURIComponent(parentId)}` : ''; + return apiFetch(`/teams/${teamId}/files${qs}`); + }, + + createFolder: (teamId: string, name: string, parent_id = '') => + apiFetch(`/teams/${teamId}/files/folder`, { + method: 'POST', + body: JSON.stringify({ name, parent_id }) + }), + + uploadFile: (teamId: string, file: File, parent_id = '') => { + const fd = new FormData(); + fd.append('file', file); + if (parent_id) fd.append('parent_id', parent_id); + return apiFetchFormData(`/teams/${teamId}/files/upload`, fd); + }, + + uploadAvatar: (teamId: string, file: File) => { + const fd = new FormData(); + fd.append('file', file); + return apiFetchFormData(`/teams/${teamId}/avatar`, fd); + }, + + uploadBanner: (teamId: string, file: File) => { + const fd = new FormData(); + fd.append('file', file); + return apiFetchFormData(`/teams/${teamId}/banner`, fd); + }, + + listChatMessages: (teamId: string, before?: string) => { + const qs = before ? `?before=${encodeURIComponent(before)}` : ''; + return apiFetch(`/teams/${teamId}/chat${qs}`); + } +}; + +export const projects = { + list: () => apiFetch('/projects'), + + createPersonal: (name: string, description: string) => + apiFetch('/projects', { + method: 'POST', + body: JSON.stringify({ name, description }) + }), + + get: (projectId: string) => apiFetch(`/projects/${projectId}`), + + update: (projectId: string, data: Partial>) => + apiFetch(`/projects/${projectId}`, { method: 'PUT', body: JSON.stringify(data) }), + + archive: (projectId: string) => + apiFetch(`/projects/${projectId}/archive`, { method: 'PUT' }), + + delete: (projectId: string) => apiFetch(`/projects/${projectId}`, { method: 'DELETE' }), + + listMembers: (projectId: string) => apiFetch(`/projects/${projectId}/members`), + + addMember: (projectId: string, userId: string, role_flags: number) => + apiFetch(`/projects/${projectId}/members`, { + method: 'POST', + body: JSON.stringify({ user_id: userId, role_flags }) + }), + + updateMemberRole: (projectId: string, userId: string, role_flags: number) => + apiFetch(`/projects/${projectId}/members/${userId}`, { + method: 'PUT', + body: JSON.stringify({ role_flags }) + }), + + removeMember: (projectId: string, userId: string) => + apiFetch(`/projects/${projectId}/members/${userId}`, { method: 'DELETE' }), + + listEvents: (projectId: string) => apiFetch(`/projects/${projectId}/events`), + + createEvent: ( + projectId: string, + data: Pick + ) => + apiFetch(`/projects/${projectId}/events`, { + method: 'POST', + body: JSON.stringify(data) + }), + + listFiles: (projectId: string, parentId = '') => { + const qs = parentId ? `?parent_id=${encodeURIComponent(parentId)}` : ''; + return apiFetch(`/projects/${projectId}/files${qs}`); + }, + + createFolder: (projectId: string, name: string, parent_id = '') => + apiFetch(`/projects/${projectId}/files/folder`, { + method: 'POST', + body: JSON.stringify({ name, parent_id }) + }), + + uploadFile: (projectId: string, file: File, parent_id = '') => { + const fd = new FormData(); + fd.append('file', file); + if (parent_id) fd.append('parent_id', parent_id); + return apiFetchFormData(`/projects/${projectId}/files/upload`, fd); + }, + + listWebhooks: (projectId: string) => apiFetch(`/projects/${projectId}/webhooks`), + + createWebhook: (projectId: string, data: Pick) => + apiFetch(`/projects/${projectId}/webhooks`, { + method: 'POST', + body: JSON.stringify(data) + }), + + getWhiteboard: (projectId: string) => + apiFetch(`/projects/${projectId}/whiteboard`), + + saveWhiteboard: (projectId: string, data: string) => + apiFetch(`/projects/${projectId}/whiteboard`, { + method: 'PUT', + body: JSON.stringify({ data }) + }) +}; + +export const board = { + get: (projectId: string) => apiFetch(`/projects/${projectId}/board`), + + createColumn: (projectId: string, title: string) => + apiFetch(`/projects/${projectId}/columns`, { + method: 'POST', + body: JSON.stringify({ title }) + }), + + updateColumn: (projectId: string, columnId: string, title: string) => + apiFetch(`/projects/${projectId}/columns/${columnId}`, { + method: 'PUT', + body: JSON.stringify({ title }) + }), + + reorderColumn: (projectId: string, columnId: string, position: number) => + apiFetch<{ id: string; position: number }>( + `/projects/${projectId}/columns/${columnId}/position`, + { method: 'PUT', body: JSON.stringify({ position }) } + ), + + deleteColumn: (projectId: string, columnId: string) => + apiFetch(`/projects/${projectId}/columns/${columnId}`, { method: 'DELETE' }), + + createCard: ( + projectId: string, + columnId: string, + data: Pick + ) => + apiFetch(`/projects/${projectId}/columns/${columnId}/cards`, { + method: 'POST', + body: JSON.stringify(data) + }) +}; + +export const cards = { + update: ( + cardId: string, + data: Partial> + ) => apiFetch(`/cards/${cardId}`, { method: 'PUT', body: JSON.stringify(data) }), + + move: (cardId: string, column_id: string, position: number) => + apiFetch(`/cards/${cardId}/move`, { + method: 'PUT', + body: JSON.stringify({ column_id, position }) + }), + + delete: (cardId: string) => apiFetch(`/cards/${cardId}`, { method: 'DELETE' }) +}; + +export const events = { + update: ( + eventId: string, + data: Partial> + ) => apiFetch(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(data) }), + + delete: (eventId: string) => apiFetch(`/events/${eventId}`, { method: 'DELETE' }) +}; + +export const notifications = { + list: () => apiFetch('/notifications'), + + markRead: (notifId: string) => + apiFetch(`/notifications/${notifId}/read`, { method: 'PUT' }), + + markAllRead: () => apiFetch('/notifications/read-all', { method: 'PUT' }), + + delete: (notifId: string) => apiFetch(`/notifications/${notifId}`, { method: 'DELETE' }) +}; + +export const docs = { + get: (docId: string) => apiFetch(`/docs/${docId}`), + + update: (docId: string, data: Partial>) => + apiFetch(`/docs/${docId}`, { method: 'PUT', body: JSON.stringify(data) }), + + delete: (docId: string) => apiFetch(`/docs/${docId}`, { method: 'DELETE' }) +}; + +export const files = { + delete: (fileId: string) => apiFetch(`/files/${fileId}`, { method: 'DELETE' }), + downloadUrl: (fileId: string) => `/api/files/${fileId}/download`, + download: async (fileId: string, fileName: string) => { + const { getAccessToken } = await import('./client'); + const token = getAccessToken(); + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`/api/files/${fileId}/download`, { headers }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + } +}; + +export const webhooks = { + update: (webhookId: string, data: Partial>) => + apiFetch(`/webhooks/${webhookId}`, { method: 'PUT', body: JSON.stringify(data) }), + + toggle: (webhookId: string) => + apiFetch(`/webhooks/${webhookId}/toggle`, { method: 'PUT' }), + + delete: (webhookId: string) => apiFetch(`/webhooks/${webhookId}`, { method: 'DELETE' }) +}; + +export const apiKeys = { + list: () => apiFetch('/users/me/api-keys'), + + create: (name: string, scopes: string[]) => + apiFetch('/users/me/api-keys', { + method: 'POST', + body: JSON.stringify({ name, scopes }) + }), + + revoke: (keyId: string) => apiFetch(`/users/me/api-keys/${keyId}`, { method: 'DELETE' }) +}; diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/src/lib/components/Calendar/Calendar.svelte b/src/lib/components/Calendar/Calendar.svelte new file mode 100644 index 0000000..44b126a --- /dev/null +++ b/src/lib/components/Calendar/Calendar.svelte @@ -0,0 +1,284 @@ + + +
+
+
+
+ + +
+

+ {#if viewMode === 'month'} + {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()} + {:else} + {monthNames[weekStart.getMonth()].slice(0,3)} {weekStart.getDate()} – + {monthNames[weekDays[6].getMonth()].slice(0,3)} {weekDays[6].getDate()}, {weekDays[6].getFullYear()} + {/if} +

+ +
+ +
+ + +
+
+ + {#if viewMode === 'month'} +
+ {#each dayNames as day} +
+ {day} +
+ {/each} +
+ +
+ {#each Array(firstDayOfMonth) as _} +
+ {/each} + + {#each Array(daysInMonth) as _, i} + {@const dayNum = i + 1} + {@const cellDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), dayNum)} + {@const dayEvents = getEventsForDate(cellDate)} + {@const isToday = isSameDay(cellDate, new Date())} + {@const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6} + +
+
+ + {dayNum} + +
+ +
+ {#each dayEvents.slice(0, 3) as event} + + {/each} + {#if dayEvents.length > 3} + + {/if} +
+
+ {/each} + + {#each Array((7 - ((firstDayOfMonth + daysInMonth) % 7)) % 7) as _} +
+ {/each} +
+ + {:else} +
+ {#each weekDays as day} + {@const isToday = isSameDay(day, new Date())} +
+
{dayNames[day.getDay()]}
+
{day.getDate()}
+ {#if isToday} +
+ {/if} +
+ {/each} +
+ +
+ {#each weekDays as day} + {@const isToday = isSameDay(day, new Date())} + {@const dayEvents = getEventsForDate(day)} +
+ {#each dayEvents as event} + + {/each} + {#if dayEvents.length === 0} +
+ No events +
+ {/if} +
+ {/each} +
+ {/if} +
+ +{#if selectedEvent} + +
+
+ +
+

{selectedEvent.title}

+ {#if selectedEvent.date} +

+ + {formatDate(selectedEvent.date)} +

+ {/if} + {#if selectedEvent.time} +

+ + {selectedEvent.time} +

+ {/if} +
+
+ + {#if selectedEvent.description} +
+

{selectedEvent.description}

+
+ {/if} + +
+ +
+
+
+{/if} diff --git a/src/lib/components/FileViewer/FileViewer.svelte b/src/lib/components/FileViewer/FileViewer.svelte new file mode 100644 index 0000000..4d1bf3d --- /dev/null +++ b/src/lib/components/FileViewer/FileViewer.svelte @@ -0,0 +1,243 @@ + + + + +{#if file} +
+
+
+
{file.name}
+ {#if file.size_bytes} +
{formatSize(file.size_bytes)}
+ {/if} +
+
+ + +
+
+ +
+ {#if activeType === 'pdf'} + {#if blobLoading} +
Loading…
+ {:else if blobError} +
Failed to load: {blobError}
+ {:else} + + {/if} + + {:else if activeType === 'image'} +
+ {#if blobLoading} +
Loading…
+ {:else if blobError} +
Failed to load: {blobError}
+ {:else} + {file.name} + {/if} +
+ + {:else if activeType === 'video'} +
+ {#if blobLoading} +
Loading…
+ {:else if blobError} +
Failed to load: {blobError}
+ {:else} + + + {/if} +
+ + {:else if activeType === 'audio'} +
+ {#if blobLoading} +
Loading…
+ {:else if blobError} +
Failed to load: {blobError}
+ {:else} +
+ +
+
{file.name}
+ + {/if} +
+ + {:else if activeType === 'markdown'} +
+ {#if textLoading} +
Loading…
+ {:else if textError} +
Failed to load: {textError}
+ {:else} +
+ +
+ {/if} +
+ + {:else if activeType === 'text'} +
+ {#if textLoading} +
Loading…
+ {:else if textError} +
Failed to load: {textError}
+ {:else} +
{textContent}
+ {/if} +
+ + {:else} +
+ +

No preview available for this file type.

+ +
+ {/if} +
+
+{/if} diff --git a/src/lib/components/Markdown/Markdown.svelte b/src/lib/components/Markdown/Markdown.svelte new file mode 100644 index 0000000..4ae29d3 --- /dev/null +++ b/src/lib/components/Markdown/Markdown.svelte @@ -0,0 +1,43 @@ + + + diff --git a/src/lib/components/Modal/Modal.svelte b/src/lib/components/Modal/Modal.svelte new file mode 100644 index 0000000..2ebd42c --- /dev/null +++ b/src/lib/components/Modal/Modal.svelte @@ -0,0 +1,29 @@ + + +{#if isOpen} +
+ + +
+ +
+
+

{title}

+ +
+ +
+ {@render children()} +
+
+
+{/if} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/stores/auth.svelte.ts b/src/lib/stores/auth.svelte.ts new file mode 100644 index 0000000..ded661b --- /dev/null +++ b/src/lib/stores/auth.svelte.ts @@ -0,0 +1,77 @@ +import { setAccessToken } from '$lib/api/client'; +import { auth as authApi, users as usersApi } from '$lib/api'; +import type { User } from '$lib/types/api'; + +function createAuthStore() { + let user = $state(null); + let loading = $state(true); + + async function init() { + const token = + typeof localStorage !== 'undefined' ? localStorage.getItem('access_token') : null; + if (!token) { + loading = false; + return; + } + try { + user = await usersApi.me(); + } catch { + user = null; + } finally { + loading = false; + } + } + + async function login(email: string, password: string) { + const res = await authApi.login(email, password); + setAccessToken(res.access_token); + if (typeof localStorage !== 'undefined') { + localStorage.setItem('refresh_token', res.refresh_token); + localStorage.setItem('user_id', res.user.id); + } + user = res.user; + } + + async function register(name: string, email: string, password: string) { + const res = await authApi.register(name, email, password); + setAccessToken(res.access_token); + if (typeof localStorage !== 'undefined') { + localStorage.setItem('refresh_token', res.refresh_token); + localStorage.setItem('user_id', res.user.id); + } + user = res.user; + } + + async function logout() { + try { + await authApi.logout(); + } catch { + } + setAccessToken(null); + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user_id'); + } + user = null; + } + + function setUser(u: User) { + user = u; + } + + return { + get user() { + return user; + }, + get loading() { + return loading; + }, + init, + login, + register, + logout, + setUser + }; +} + +export const authStore = createAuthStore(); diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts new file mode 100644 index 0000000..4899eb7 --- /dev/null +++ b/src/lib/types/api.ts @@ -0,0 +1,193 @@ +export interface User { + id: string; + name: string; + email: string; + avatar_url: string; + created_at: string; + updated_at: string; +} + +export interface Team { + id: string; + name: string; + workspace_id: string; + avatar_url?: string; + banner_url?: string; + member_count: number; + role_flags: number; + role_name: string; + created_at: string; + updated_at?: string; +} + +export interface TeamMember { + id: string; + user_id: string; + team_id?: string; + name: string; + email: string; + role_flags: number; + role_name: string; + joined_at: string; +} + +export interface Project { + id: string; + team_id: string; + team_name?: string; + name: string; + description: string; + visibility?: string; + is_public: boolean; + is_archived: boolean; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface ProjectMember { + id: string; + user_id: string; + project_id: string; + name: string; + email: string; + role_flags: number; + role_name: string; + added_at: string; +} + +export interface Subtask { + id: number; + text: string; + done: boolean; +} + +export interface Card { + id: string; + column_id: string; + project_id: string; + title: string; + description: string; + priority: string; + color: string; + due_date: string; + assignees: string[]; + subtasks: Subtask[]; + position: number; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface Column { + id: string; + project_id: string; + title: string; + position: number; + cards?: Card[]; + created_at: string; + updated_at: string; +} + +export interface BoardData { + project_id: string; + columns: Column[]; +} + +export interface Event { + id: string; + title: string; + date: string; + time: string; + color: string; + description: string; + scope: string; + scope_id: string; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface Notification { + id: string; + user_id: string; + type: string; + message: string; + project_id: string; + read: boolean; + created_at: string; +} + +export interface Doc { + id: string; + team_id: string; + title: string; + content: string; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface FileItem { + id: string; + project_id?: string; + team_id?: string; + user_id?: string; + parent_id?: string; + name: string; + type: string; + size_bytes: number; + storage_url: string; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface Webhook { + id: string; + project_id: string; + name: string; + type: string; + url: string; + status: string; + last_triggered?: string; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface Whiteboard { + id: string; + project_id: string; + data: string; + updated_at: string; +} + +export interface AuthResponse { + access_token: string; + refresh_token: string; + user: User; +} + +export interface ApiKey { + id: string; + name: string; + scopes: string[]; + prefix: string; + last_used?: string; + created_at: string; +} + +/** Returned only once when a key is first created — contains the raw key. */ +export interface ApiKeyCreated extends ApiKey { + key: string; +} + +export interface ChatMessage { + id: string; + team_id: string; + user_id: string; + user_name: string; + content: string; + created_at: string; +} diff --git a/src/lib/types/roles.ts b/src/lib/types/roles.ts new file mode 100644 index 0000000..edb6605 --- /dev/null +++ b/src/lib/types/roles.ts @@ -0,0 +1,10 @@ +export enum RoleFlag { + Viewer = 1 << 0, // 1 + Editor = 1 << 1, // 2 + Admin = 1 << 2, // 4 + Owner = 1 << 3, // 8 +} + +export function hasPermission(userRole: number, requiredRole: number): boolean { + return (userRole & requiredRole) === requiredRole; +} diff --git a/src/lib/utils/fileRefs.ts b/src/lib/utils/fileRefs.ts new file mode 100644 index 0000000..719aea8 --- /dev/null +++ b/src/lib/utils/fileRefs.ts @@ -0,0 +1,11 @@ +import type { FileItem } from '../types/api'; + +export function resolveFileRefs(content: string, files: FileItem[]): string { + return content.replace(/\$file:([^\s\])"']+)/g, (_match, name: string) => { + const file = files.find(f => f.name === name && f.type !== 'folder'); + if (file) { + return `[${name}](#file-dl:${file.id}:${encodeURIComponent(name)})`; + } + return `\`unknown file: ${name}\``; + }); +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..450c1f8 --- /dev/null +++ b/src/routes/(app)/+layout.svelte @@ -0,0 +1,252 @@ + + +
+ +
+ + + +
+ + +
+
+
+
+ {@render children()} +
+ + +
+
+
+
+ FPMB + · + Free Project Management Boards +
+
+ API Docs + Settings + v0.1.0 +
+
+
+
+
+
+
+
diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte new file mode 100644 index 0000000..1bd51c2 --- /dev/null +++ b/src/routes/(app)/+page.svelte @@ -0,0 +1,237 @@ + + + + Dashboard — FPMB + + + +
+
+

Dashboard

+

+ Welcome back. Here's an overview of your teams and active projects. +

+
+ + +
+
+

+ + My Teams +

+ +
+ + {#if loading} +

Loading...

+ {:else if myTeams.length === 0} +

+ You're not a member of any teams yet. +

+ {:else} + + {/if} +
+ + +
+
+

+ + Recent Projects +

+ + View All + +
+ + {#if loading} +

Loading...

+ {:else if recentProjects.length === 0} +

No projects yet.

+ {:else} + + {/if} +
+
+ +{#if showNewTeam} +
(showNewTeam = false)} + > +
e.stopPropagation()} + > +
+

Create Team

+ +
+
+
+ + +
+
+ + +
+
+
+
+{/if} diff --git a/src/routes/(app)/api-docs/+page.svelte b/src/routes/(app)/api-docs/+page.svelte new file mode 100644 index 0000000..8f0b120 --- /dev/null +++ b/src/routes/(app)/api-docs/+page.svelte @@ -0,0 +1,633 @@ + + + + API Documentation — FPMB + + + +
+ +
+
+
+ + + +
+

+ API Documentation +

+
+

+ REST API reference for programmatic access to FPMB. All endpoints are + prefixed with /api. +

+
+ +
+ + + + +
+ +
+

+ Overview +

+
+

+ The FPMB REST API lets you integrate with projects, boards, teams, + files, and more. All responses are JSON. +

+
+ {#each [{ label: "Base URL", value: "http://localhost:8080/api" }, { label: "Content-Type", value: "application/json" }, { label: "Auth Scheme", value: "Bearer token / API key" }] as item} +
+

{item.label}

+

{item.value}

+
+ {/each} +
+
+

+ Note: Protected endpoints require an + Authorization: Bearer <token> header. Tokens can be JWT access tokens (from login) or personal + API keys. +

+
+
+
+ + +
+

+ Authentication +

+
+ {#each [{ method: "POST", path: "/auth/register", label: "Register", desc: "Create a new account. Returns access + refresh tokens.", body: `{ "name": "Alice", "email": "alice@example.com", "password": "secret" }`, response: `{ "access_token": "eyJ...", "refresh_token": "eyJ...", "user": { ... } }` }, { method: "POST", path: "/auth/login", label: "Login", desc: "Authenticate with email and password.", body: `{ "email": "alice@example.com", "password": "secret" }`, response: `{ "access_token": "eyJ...", "refresh_token": "eyJ..." }` }, { method: "POST", path: "/auth/refresh", label: "Refresh Token", desc: "Exchange a refresh token for a new access token.", body: `{ "refresh_token": "eyJ..." }`, response: `{ "access_token": "eyJ...", "refresh_token": "eyJ..." }` }, { method: "POST", path: "/auth/logout", label: "Logout", desc: "Invalidate the current session (requires auth).", body: null, response: `{ "message": "Logged out successfully" }` }] as ep} +
+
+ {ep.method} + {ep.path} + {ep.label} +
+
{ep.desc}
+ {#if ep.body} +
+

Request body

+
{ep.body}
+
+ {/if} +
+

Response

+
{ep.response}
+
+
+ {/each} +
+
+ + +
+

+ API Keys +

+

+ Personal API keys can be used instead of JWT tokens. Pass them the + same way: Authorization: Bearer fpmb_... +

+ + +
+
+

Available Scopes

+
+ + + + + + + + + {#each [["read:projects", "List and view projects"], ["write:projects", "Create, update, and delete projects"], ["read:boards", "Read board columns and cards"], ["write:boards", "Create, move, and delete cards and columns"], ["read:teams", "List teams and their members"], ["write:teams", "Create and manage teams"], ["read:files", "Browse and download files"], ["write:files", "Upload, create folders, and delete files"], ["read:notifications", "Read notifications"]] as [scope, desc]} + + + + + {/each} + +
ScopeDescription
{scope}{desc}
+
+ +
+ {#each [{ method: "GET", path: "/users/me/api-keys", label: "List keys", auth: true, desc: "Returns all active (non-revoked) API keys for the authenticated user.", body: null, response: `[{ "id": "...", "name": "CI Pipeline", "scopes": ["read:projects"], "prefix": "fpmb_ab12", "created_at": "..." }]` }, { method: "POST", path: "/users/me/api-keys", label: "Create key", auth: true, desc: "Generate a new API key. The raw key is returned only once.", body: `{ "name": "My Key", "scopes": ["read:projects", "read:boards"] }`, response: `{ "id": "...", "name": "My Key", "key": "fpmb_...", "scopes": [...], "prefix": "fpmb_ab12", "created_at": "..." }` }, { method: "DELETE", path: "/users/me/api-keys/:keyId", label: "Revoke key", auth: true, desc: "Permanently revokes an API key. This cannot be undone.", body: null, response: `{ "message": "Key revoked" }` }] as ep} +
+
+ {ep.method} + {ep.path} + {#if ep.auth}🔒 auth{/if} + {ep.label} +
+
{ep.desc}
+ {#if ep.body} +
+

Request body

+
{ep.body}
+
+ {/if} +
+

Response

+
{ep.response}
+
+
+ {/each} +
+
+ + +
+

+ Users +

+
+ {#each [{ method: "GET", path: "/users/me", label: "Get current user", desc: "Returns the authenticated user's profile." }, { method: "PUT", path: "/users/me", label: "Update profile", desc: "Update name, email, or avatar_url.", body: '{ "name": "Alice", "email": "alice@example.com" }' }, { method: "PUT", path: "/users/me/password", label: "Change password", desc: "Change account password.", body: '{ "current_password": "old", "new_password": "new" }' }, { method: "POST", path: "/users/me/avatar", label: "Upload avatar", desc: "Multipart/form-data. Field: file." }, { method: "GET", path: "/users/me/avatar", label: "Get avatar", desc: "Returns the avatar image file." }, { method: "GET", path: "/users/search?q=", label: "Search users", desc: "Search by name or email. Returns up to 10 results." }] as ep} +
+
+ {ep.method} + {ep.path} + 🔒 auth + {ep.label} +
+ {#if ep.desc || ep.body} +
+ {ep.desc} + {#if ep.body} +
{ep.body}
+ {/if} +
+ {/if} +
+ {/each} +
+
+ + +
+

+ Teams +

+
+ {#each [{ method: "GET", path: "/teams", label: "List teams" }, { method: "POST", path: "/teams", label: "Create team", body: '{ "name": "Engineering" }' }, { method: "GET", path: "/teams/:teamId", label: "Get team" }, { method: "PUT", path: "/teams/:teamId", label: "Update team", body: '{ "name": "New Name" }' }, { method: "DELETE", path: "/teams/:teamId", label: "Delete team" }, { method: "GET", path: "/teams/:teamId/members", label: "List members" }, { method: "POST", path: "/teams/:teamId/members/invite", label: "Invite member", body: '{ "email": "bob@example.com", "role_flags": 1 }' }, { method: "PUT", path: "/teams/:teamId/members/:userId", label: "Update member role", body: '{ "role_flags": 2 }' }, { method: "DELETE", path: "/teams/:teamId/members/:userId", label: "Remove member" }, { method: "GET", path: "/teams/:teamId/projects", label: "List team projects" }, { method: "POST", path: "/teams/:teamId/projects", label: "Create project", body: '{ "name": "Sprint 1", "description": "..." }' }, { method: "GET", path: "/teams/:teamId/events", label: "List team events" }, { method: "POST", path: "/teams/:teamId/events", label: "Create event" }, { method: "GET", path: "/teams/:teamId/docs", label: "List docs" }, { method: "POST", path: "/teams/:teamId/docs", label: "Create doc", body: '{ "title": "RFC", "content": "..." }' }, { method: "GET", path: "/teams/:teamId/files", label: "List files" }, { method: "POST", path: "/teams/:teamId/files/folder", label: "Create folder" }, { method: "POST", path: "/teams/:teamId/files/upload", label: "Upload file (multipart)" }, { method: "POST", path: "/teams/:teamId/avatar", label: "Upload avatar" }, { method: "POST", path: "/teams/:teamId/banner", label: "Upload banner" }] as ep} +
+
+ {ep.method} + {ep.path} + {ep.label} +
+ {#if ep.body} +
{ep.body}
+ {/if} +
+ {/each} +
+
+ + +
+

+ Projects +

+
+ {#each [{ method: "GET", path: "/projects", label: "List all projects" }, { method: "POST", path: "/projects", label: "Create personal project", body: '{ "name": "My Project", "description": "..." }' }, { method: "GET", path: "/projects/:projectId", label: "Get project" }, { method: "PUT", path: "/projects/:projectId", label: "Update project", body: '{ "name": "New Name", "description": "..." }' }, { method: "PUT", path: "/projects/:projectId/archive", label: "Archive project" }, { method: "DELETE", path: "/projects/:projectId", label: "Delete project" }, { method: "GET", path: "/projects/:projectId/members", label: "List members" }, { method: "POST", path: "/projects/:projectId/members", label: "Add member", body: '{ "user_id": "...", "role_flags": 1 }' }, { method: "PUT", path: "/projects/:projectId/members/:userId", label: "Update member role" }, { method: "DELETE", path: "/projects/:projectId/members/:userId", label: "Remove member" }, { method: "GET", path: "/projects/:projectId/events", label: "List events" }, { method: "POST", path: "/projects/:projectId/events", label: "Create event" }, { method: "GET", path: "/projects/:projectId/files", label: "List files" }, { method: "POST", path: "/projects/:projectId/files/folder", label: "Create folder" }, { method: "POST", path: "/projects/:projectId/files/upload", label: "Upload file (multipart)" }, { method: "GET", path: "/projects/:projectId/webhooks", label: "List webhooks" }, { method: "POST", path: "/projects/:projectId/webhooks", label: "Create webhook" }, { method: "GET", path: "/projects/:projectId/whiteboard", label: "Get whiteboard data" }, { method: "PUT", path: "/projects/:projectId/whiteboard", label: "Save whiteboard", body: '{ "data": "" }' }] as ep} +
+
+ {ep.method} + {ep.path} + {ep.label} +
+ {#if ep.body} +
{ep.body}
+ {/if} +
+ {/each} +
+
+ + +
+

+ Boards & Cards +

+
+ {#each [{ method: "GET", path: "/projects/:projectId/board", label: "Get board (columns + cards)" }, { method: "POST", path: "/projects/:projectId/columns", label: "Create column", body: '{ "title": "To Do" }' }, { method: "PUT", path: "/projects/:projectId/columns/:columnId", label: "Rename column", body: '{ "title": "In Progress" }' }, { method: "PUT", path: "/projects/:projectId/columns/:columnId/position", label: "Reorder column", body: '{ "position": 2 }' }, { method: "DELETE", path: "/projects/:projectId/columns/:columnId", label: "Delete column" }, { method: "POST", path: "/projects/:projectId/columns/:columnId/cards", label: "Create card", body: '{ "title": "Task", "priority": "high", "assignees": ["email@x.com"] }' }, { method: "PUT", path: "/cards/:cardId", label: "Update card" }, { method: "PUT", path: "/cards/:cardId/move", label: "Move card", body: '{ "column_id": "...", "position": 0 }' }, { method: "DELETE", path: "/cards/:cardId", label: "Delete card" }] as ep} +
+
+ {ep.method} + {ep.path} + {ep.label} +
+ {#if ep.body} +
{ep.body}
+ {/if} +
+ {/each} +
+
+ + +
+

+ Events +

+
+ {#each [{ method: "PUT", path: "/events/:eventId", label: "Update event", body: '{ "title": "Sprint Review", "date": "2025-03-01", "time": "14:00", "color": "#6366f1" }' }, { method: "DELETE", path: "/events/:eventId", label: "Delete event" }] as ep} +
+
+ {ep.method} + {ep.path} + {ep.label} +
+ {#if ep.body} +
{ep.body}
+ {/if} +
+ {/each} +
+
+ + +
+

+ Files +

+
+ {#each [{ method: "GET", path: "/users/me/files", label: "List personal files", note: "?parent_id= for folder navigation" }, { method: "POST", path: "/users/me/files/folder", label: "Create folder", body: '{ "name": "Designs", "parent_id": "" }' }, { method: "POST", path: "/users/me/files/upload", label: "Upload file", note: "Multipart: file field + optional parent_id" }, { method: "GET", path: "/files/:fileId/download", label: "Download file" }, { method: "DELETE", path: "/files/:fileId", label: "Delete file" }] as ep} +
+
+ {ep.method} + {ep.path} + {ep.label} +
+ {#if ep.body || ep.note} +

+ {ep.body ?? ep.note} +

+ {/if} +
+ {/each} +
+
+ + +
+

+ Webhooks +

+

+ Webhook types: discord + · + github + · gitea + · slack + · + custom +

+
+ {#each [{ method: "GET", path: "/projects/:projectId/webhooks", label: "List webhooks" }, { method: "POST", path: "/projects/:projectId/webhooks", label: "Create webhook", body: '{ "name": "Deploy notify", "url": "https://...", "type": "discord" }' }, { method: "PUT", path: "/webhooks/:webhookId", label: "Update webhook" }, { method: "PUT", path: "/webhooks/:webhookId/toggle", label: "Enable / disable" }, { method: "DELETE", path: "/webhooks/:webhookId", label: "Delete webhook" }] as ep} +
+
+ {ep.method} + {ep.path} + {ep.label} +
+ {#if ep.body} +
{ep.body}
+ {/if} +
+ {/each} +
+
+ + +
+

+ Notifications +

+
+ {#each [{ method: "GET", path: "/notifications", label: "List notifications" }, { method: "PUT", path: "/notifications/read-all", label: "Mark all as read" }, { method: "PUT", path: "/notifications/:notifId/read", label: "Mark one as read" }, { method: "DELETE", path: "/notifications/:notifId", label: "Delete notification" }] as ep} +
+
+ {ep.method} + {ep.path} + {ep.label} +
+
+ {/each} +
+
+ + +
+

+ + Quick Example — curl +

+
+
curl -X GET https://your-fpmb-instance/api/projects \
+  -H "Authorization: Bearer fpmb_your_api_key_here" \
+  -H "Content-Type: application/json"
+ +
+

+ Generate an API key in User Settings → API Keys. +

+
+
+
+
diff --git a/src/routes/(app)/board/[id]/+page.svelte b/src/routes/(app)/board/[id]/+page.svelte new file mode 100644 index 0000000..2129075 --- /dev/null +++ b/src/routes/(app)/board/[id]/+page.svelte @@ -0,0 +1,1459 @@ + + + + {projectName ? `${projectName} — FPMB` : "Board — FPMB"} + + + +
+
+
+

+ {projectName || `Board #${boardId}`} +

+
+
+ + + + + + + +
+
+ + {#if loading} +

Loading board...

+ {:else} + +
+ {#each boardViews as v} + + {/each} +
+ + {#if isArchived} +
+ + This project is archived and is in read-only mode. +
+ {/if} + + {#if currentView === "kanban"} +
+
+ {#each columns as column (column.id)} +
handleDrop(column.id, e)} + role="list" + > +
+

{column.title}

+ {column.cards.length} +
+ +
+ {#each column.cards as card (card.id)} +
handleDragStart(card.id, column.id, e)} + class="bg-neutral-750 p-4 rounded-md border border-neutral-600 shadow-sm {isArchived + ? 'cursor-default' + : 'cursor-grab active:cursor-grabbing'} hover:border-neutral-500 transition-colors flex flex-col gap-2 group relative overflow-hidden" + role="listitem" + onclick={() => + !isArchived && openEditTaskModal(column.id, card)} + onkeydown={(e) => + e.key === "Enter" && + !isArchived && + openEditTaskModal(column.id, card)} + tabindex="0" + > + {#if card.color && card.color !== "neutral"} +
+ {/if} + +
+
+ + + {card.priority} + +
+ +
+ +

{card.title}

+ + {#if card.subtasks && card.subtasks.length > 0} +
+ + {card.subtasks.filter((st) => st.done).length}/{card + .subtasks.length} +
+ {/if} + +
+
+ {#if card.due_date} +
+ + {new Date(card.due_date).toLocaleDateString( + "en-US", + { + month: "2-digit", + day: "2-digit", + year: "numeric", + }, + )} +
+ {/if} +
+ {#if card.assignees && card.assignees.length > 0} +
+ {#each card.assignees as assignee} +
+ {assignee.charAt(0)} +
+ {/each} +
+ {/if} +
+
+ {/each} +
+ +
+ {#if !isArchived} + + {/if} +
+
+ {/each} + + {#if !isArchived} + + {/if} +
+
+ {:else if currentView === "table"} + +
+ + + + + + + + + + + + + {#each allCards as card (card.id)} + + !isArchived && openEditTaskModal(card.columnId, card)} + > + + + + + + + + {:else} + + {/each} + +
TaskStatusPriorityDue DateAssigneesSubtasks
+
+ {#if card.color && card.color !== "neutral"} +
+ {/if} + {card.title} +
+
+ {card.columnTitle} + + + + {card.priority} + + + {#if card.due_date} + {new Date(card.due_date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + {:else} + + {/if} + + {#if card.assignees?.length} +
+ {#each card.assignees as a} +
+ {a.charAt(0)} +
+ {/each} +
+ {:else} + + {/if} +
+ {#if card.subtasks?.length} + {card.subtasks.filter((s) => s.done).length}/{card.subtasks + .length} + {:else} + + {/if} +
No tasks yet.
+
+ {:else if currentView === "gantt"} + + {@const now = new Date()} + {@const datedCards = allCards.filter((c) => c.due_date)} + {@const ganttStart = datedCards.length + ? new Date( + Math.min( + ...datedCards.map((c) => + new Date(c.created_at || c.due_date).getTime(), + ), + now.getTime() - 7 * 86400000, + ), + ) + : new Date(now.getTime() - 14 * 86400000)} + {@const ganttEnd = datedCards.length + ? new Date( + Math.max( + ...datedCards.map((c) => new Date(c.due_date).getTime()), + now.getTime() + 7 * 86400000, + ), + ) + : new Date(now.getTime() + 30 * 86400000)} + {@const totalDays = Math.max( + Math.ceil((ganttEnd.getTime() - ganttStart.getTime()) / 86400000), + 14, + )} +
+
+ +
+
+ Task +
+
+ {#each Array(totalDays) as _, i} + {@const d = new Date(ganttStart.getTime() + i * 86400000)} + {@const isToday = d.toDateString() === now.toDateString()} +
+ {d.getDate()}/{d.getMonth() + 1} +
+ {/each} +
+
+ + {#if datedCards.length === 0} +
+ + No tasks with due dates. Add due dates to see them on the Gantt chart. +
+ {:else} + {#each datedCards as card (card.id)} + {@const created = new Date(card.created_at || card.due_date)} + {@const due = new Date(card.due_date)} + {@const startOffset = Math.max( + 0, + ((created.getTime() - ganttStart.getTime()) / + (ganttEnd.getTime() - ganttStart.getTime())) * + 100, + )} + {@const barWidth = Math.max( + 2, + ((due.getTime() - created.getTime()) / + (ganttEnd.getTime() - ganttStart.getTime())) * + 100, + )} +
+
+ !isArchived && openEditTaskModal(card.columnId, card)} + > + {#if card.color && card.color !== "neutral"}
{/if} + {card.title} +
+
+
+ {card.columnTitle} +
+
+
+ {/each} + {/if} +
+
+ {:else if currentView === "roadmap"} + +
+
+ {#each columns as column, colIdx (column.id)} +
+ + {#if colIdx < columns.length - 1} +
+ {/if} + +
+ +
+

+ {column.title} + {column.cards.length} tasks +

+
+ +
+ {#each column.cards as card (card.id)} +
+ !isArchived && openEditTaskModal(column.id, card)} + > +
+
+ {#if card.color && card.color !== "neutral"}
{/if} + {card.title} +
+ + + {card.priority} + +
+ {#if card.due_date || card.subtasks?.length > 0} +
+ {#if card.due_date} + + + {new Date(card.due_date).toLocaleDateString( + "en-US", + { month: "short", day: "numeric" }, + )} + + {/if} + {#if card.subtasks?.length > 0} + + + {card.subtasks.filter((s) => s.done).length}/{card + .subtasks.length} + + {/if} +
+ {/if} + {#if card.subtasks?.length > 0} + {@const done = card.subtasks.filter((s) => s.done).length} + {@const pct = Math.round( + (done / card.subtasks.length) * 100, + )} +
+
+
+ {/if} +
+ {:else} +

+ No tasks in this stage +

+ {/each} +
+
+ {:else} +
+ No columns. Add columns in Kanban view first. +
+ {/each} +
+
+ {/if} + {/if} +
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + +
+
+
+ {#if previewMarkdown} +
+ {#if newTask.description} + + {:else} +

+ No description provided. +

+ {/if} +
+ {:else} + + {/if} +
+
+ +
+
+ + +
+
+ + {#if newTask.assignees.length > 0} +
+ {#each newTask.assignees as email} + + {email} + + + {/each} +
+ {/if} + + {#if showUserDropdown && userSearchResults.length > 0} +
+ {#each userSearchResults as user} + + {/each} +
+ {/if} +
+
+ +
+ +
    + {#each newTask.subtasks as subtask, i} +
  • + + {subtask.text} + +
  • + {/each} +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+

Visibility

+
+ {#each visibilityOptions as opt} + + {/each} +
+
+ + {#if selectedVisibility === "public"} +
+

Board Link

+
+ + +
+
+ {/if} + + {#if selectedVisibility === "unlisted"} +
+

Invite Members

+

+ Only people you invite can access this board. No public link is + generated. +

+
+
+
+ + {#if showMemberDropdown && memberSearchResults.length > 0} +
+ {#each memberSearchResults as user} + + {/each} +
+ {/if} +
+ +
+
+ + {#if shareMembers.length > 0} +
+ {#each shareMembers as member (member.user_id)} +
+
+ {(member.name || member.email || "?").charAt(0)} +
+
+
+ {member.name || member.email} +
+
+ {member.email} +
+
+ {#if member.role_flags >= 8} + Owner + {:else} + + + {/if} +
+ {/each} +
+ {:else} +

No members invited yet.

+ {/if} +
+ {/if} + +
+ + +
+
+
+ + diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte new file mode 100644 index 0000000..71f6f3a --- /dev/null +++ b/src/routes/(app)/calendar/+page.svelte @@ -0,0 +1,256 @@ + + + + Calendar — FPMB + + + +
+
+
+

+ Organization Calendar +

+

+ Overview of all team events and milestones. +

+
+
+ +
+
+ + {#if loading} +

Loading events...

+ {:else} + + {/if} +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
diff --git a/src/routes/(app)/docs/+page.svelte b/src/routes/(app)/docs/+page.svelte new file mode 100644 index 0000000..6b91cb9 --- /dev/null +++ b/src/routes/(app)/docs/+page.svelte @@ -0,0 +1,622 @@ + + + + Documentation — FPMB + + + +
+ + + + +
+
+

FPMB Documentation

+

+ Everything you need to know about Free Project Management Boards — + features, workflows, and tips. +

+
+ + +
+

+ Overview +

+
+

+ FPMB is a full-featured project management platform built with SvelteKit + and Go. It provides Kanban boards, + Gantt charts, roadmaps, real-time collaboration, whiteboards, team + chat, file management, and more. +

+
+ {#each [{ label: "Board Views", value: "4 types", icon: "lucide:columns-3" }, { label: "Real-time", value: "WebSocket", icon: "lucide:radio" }, { label: "File Storage", value: "Unlimited", icon: "lucide:hard-drive" }, { label: "API Access", value: "Full REST", icon: "lucide:terminal" }] as stat} +
+ +
{stat.value}
+
{stat.label}
+
+ {/each} +
+
+
+ + +
+

+ Dashboard +

+
+

The dashboard is your home page after logging in. It shows:

+
    +
  • + Your teams — all teams you belong + to with quick access +
  • +
  • + Recent projects — your most recently + updated projects +
  • +
  • + Upcoming events — events from your + calendar across all teams +
  • +
  • + Notifications — unread notification + count in the top bar +
  • +
+
+
+ + +
+

+ Teams +

+
+

+ Teams are the organizational unit in FPMB. Every project belongs to a + team (or to you personally). +

+
+

Creating a team

+

+ Go to the Dashboard and click "Create Team". You'll be the owner. +

+

Roles

+
+ {#each [{ role: "Viewer", desc: "Read-only access to projects and boards", flag: "1", icon: "lucide:eye" }, { role: "Editor", desc: "Create and edit cards, columns, docs", flag: "2", icon: "lucide:pencil" }, { role: "Admin", desc: "Manage members, settings, webhooks", flag: "4", icon: "lucide:shield" }, { role: "Owner", desc: "Full control including team deletion", flag: "8", icon: "lucide:crown" }] as r} +
+
+ + {r.role} + (flag {r.flag}) +
+

{r.desc}

+
+ {/each} +
+

Inviting members

+

+ Team page → Settings → Invite + by email. Choose a role before sending the invite. +

+
+
+
+ + +
+

+ Projects +

+
+

+ Projects contain boards, whiteboards, calendars, files, and webhooks. +

+
    +
  • + Team projects — created from a team + page, visible to all team members +
  • +
  • + Personal projects — created from + the Projects page, only you can access +
  • +
  • + Visibility — Private (members only), + Unlisted (invite-only), or Public +
  • +
  • + Archiving — archived projects become + read-only, preserving all data +
  • +
+
+

+ Project settings +

+

+ Access from the board page (gear icon) or from /projects/[id]/settings. Here you can rename, update the description, archive, or delete + the project. +

+
+
+
+ + +
+

+ Board Views +

+
+

+ Every project board supports 4 different views of the same data. Switch between them using the view switcher tabs at + the top of the board. +

+
+ {#each [{ name: "Kanban", icon: "lucide:columns-3", desc: "Classic column-based board. Drag and drop cards between columns. Add new columns and cards. This is the default view." }, { name: "Task Board", icon: "lucide:table", desc: "Spreadsheet-style table showing all tasks with Status, Priority, Due Date, Assignees, and Subtask progress in sortable rows." }, { name: "Gantt Chart", icon: "lucide:gantt-chart", desc: "Timeline view with horizontal bars from creation to due date. Day-level grid with today highlighted. Requires tasks to have due dates." }, { name: "Roadmap", icon: "lucide:milestone", desc: "Vertical milestone timeline. Each column is a stage. Shows progress bars for subtasks. Green milestones when all subtasks are complete." }] as view} +
+
+ +

{view.name}

+
+

+ {view.desc} +

+
+ {/each} +
+
+
+ + +
+

+ Cards + & Tasks +

+
+

Cards are the core unit of work. Each card has:

+
    +
  • + Title — required, displayed prominently + on the board +
  • +
  • + Description — supports + Markdown with preview toggle +
  • +
  • + Priority — Low, Medium, High, or + Urgent (color-coded badges) +
  • +
  • + Color label — visual sidebar indicator + (red, blue, green, purple, yellow) +
  • +
  • + Due date — used in calendar, Gantt + chart, and roadmap views +
  • +
  • + Assignees — mention with + @email, + shown as avatar circles +
  • +
  • + Subtasks — checklist items with completion + tracking and progress bars +
  • +
+
+

+ Moving cards +

+

+ In Kanban view, drag and drop cards between columns. The card's + position and column are updated automatically via the API. +

+
+
+
+ + +
+

+ Whiteboard +

+
+

+ Each project has a collaborative whiteboard accessible from the board + header (pen icon). +

+
+

Tools

+
+ {#each [{ tool: "Select", desc: "Click to select objects. Drag to move. Double-click to edit.", icon: "lucide:mouse-pointer" }, { tool: "Pen", desc: "Freehand drawing with configurable color and width.", icon: "lucide:pencil" }, { tool: "Rectangle", desc: "Click and drag to draw rectangles.", icon: "lucide:square" }, { tool: "Circle", desc: "Click and drag to draw circles.", icon: "lucide:circle" }, { tool: "Text", desc: "Click to place text. Set font size in toolbar.", icon: "lucide:type" }, { tool: "Eraser", desc: "Click on any object to delete it.", icon: "lucide:eraser" }] as t} +
+ +
+ {t.tool} + — {t.desc} +
+
+ {/each} +
+

Features

+
    +
  • + Undo/Redo — Ctrl+Z / Ctrl+Shift+Z + (up to 100 steps) +
  • +
  • + Export PNG — download the + whiteboard as a clean image +
  • +
  • + Real-time collaboration — + see other users' cursors live via WebSocket +
  • +
  • + Auto-save — changes are saved + as JSON objects +
  • +
+
+
+
+ + +
+

+ Team + Chat +

+
+

+ Each team has a real-time chat room accessible from the team page → Chat button. +

+
    +
  • + Real-time messaging — via WebSocket, + messages appear instantly +
  • +
  • + Persistent history — all messages + saved to the database, infinite scroll to load older messages +
  • +
  • + Typing indicators — see when teammates + are typing +
  • +
  • + Online presence — colored avatars + show who's currently in the chat +
  • +
  • + Message grouping — consecutive messages + from the same user within 5 minutes are grouped +
  • +
  • + Multi-line support — Shift+Enter + for new lines, Enter to send +
  • +
+
+
+ + +
+

+ Calendar +

+
+

FPMB provides calendars at multiple levels:

+
    +
  • + Global calendar — aggregates events + from all your teams +
  • +
  • + Team calendar — events scoped to + a specific team +
  • +
  • + Project calendar — events + card + due dates for a specific project +
  • +
+

+ Events have a title, description, date, time, and color label. Card + due dates automatically appear on their project calendar. +

+
+
+ + +
+

+ Files +

+
+

+ File storage with folder hierarchy is available at multiple levels: +

+
    +
  • + Personal files — accessible from + the top nav "Files" link +
  • +
  • + Team files — shared with all team + members +
  • +
  • + Project files — attached to a specific + project +
  • +
+

+ You can create folders, upload files, download, and delete. Files are + stored in the data/ directory on the server. +

+
+
+ + +
+

+ Team Docs +

+
+

+ Teams have a built-in document editor for shared notes, meeting + minutes, and documentation. +

+
    +
  • Create and edit documents with a title and rich content
  • +
  • + Accessible from the team page → Docs button +
  • +
  • Documents are stored per-team and visible to all members
  • +
+
+
+ + +
+

+ Notifications +

+
+

+ Notifications keep you updated on activity across your teams and + projects. +

+
    +
  • Bell icon in the navbar shows unread count
  • +
  • Mark individual notifications as read or mark all as read
  • +
  • + Triggered by team invites, task assignments, due date reminders, and + more +
  • +
  • Due date reminders run automatically every hour on the server
  • +
+
+
+ + +
+

+ Settings +

+
+

+ User settings are available at /settings/user (click your avatar in the top bar). +

+
    +
  • + Profile — update your name, email, + and avatar +
  • +
  • + Password — change your password +
  • +
  • + API keys — generate and manage API + keys (see below) +
  • +
+
+
+ + +
+

+ API Keys +

+
+

+ Generate personal API keys for programmatic access to the FPMB REST + API. +

+
+

Creating a key

+
    +
  1. + Go to Settings → API Keys +
  2. +
  3. Enter a name and select scopes (read, write, admin)
  4. +
  5. Click Generate
  6. +
  7. Copy the key immediately — it's only shown once!
  8. +
+

+ Using a key +

+
+ curl -H "Authorization: Bearer YOUR_API_KEY" \
  {typeof window !== "undefined" + ? window.location.origin + : "https://your-domain.com"}/api/projects +
+

+ See the API Documentation for all available endpoints. +

+
+
+
+ + +
+

+ Webhooks +

+
+

+ Set up webhooks to receive HTTP notifications when events occur in + your projects. +

+
    +
  • + Configure from Project Settings → Webhooks +
  • +
  • + Choose event types: card created, card moved, card deleted, etc. +
  • +
  • Provide a URL — FPMB will POST JSON payloads to it
  • +
  • Toggle webhooks on/off without deleting them
  • +
  • View last triggered timestamp for debugging
  • +
+
+
+ + +
+

+ Keyboard Shortcuts +

+
+
+ {#each [{ keys: "Ctrl + Z", action: "Undo (whiteboard)" }, { keys: "Ctrl + Shift + Z", action: "Redo (whiteboard)" }, { keys: "Ctrl + Y", action: "Redo (whiteboard, alt)" }, { keys: "Delete / Backspace", action: "Delete selected object (whiteboard)" }, { keys: "Escape", action: "Deselect / close editing" }, { keys: "Enter", action: "Send message (chat) / confirm text (whiteboard)" }, { keys: "Shift + Enter", action: "New line in chat" }, { keys: "Double-click", action: "Edit object (whiteboard)" }] as shortcut} +
+ {shortcut.action} + {shortcut.keys} +
+ {/each} +
+
+
+ +
+
+
diff --git a/src/routes/(app)/files/+page.svelte b/src/routes/(app)/files/+page.svelte new file mode 100644 index 0000000..d47753b --- /dev/null +++ b/src/routes/(app)/files/+page.svelte @@ -0,0 +1,382 @@ + + + + My Files — FPMB + + + +
+
+
+ + + +
+

+ My Files +

+
+ + {#each folderStack as crumb, i} + / + + {/each} + / +
+
+
+
+ + + +
+
+ + {#if showFolderInput} +
+ + + +
+ {/if} + +
+ {#if loading} +
Loading files...
+ {:else} + + + + + + + + + + + {#each fileList as file (file.id)} + + file.type === "folder" + ? openFolder(file) + : (viewingFile = file)} + > + + + + + + {/each} + + {#if fileList.length === 0} + + + + {/if} + +
NameActions
+
+
+ {@html getIcon(file.type)} +
+
+ {#if file.type === "folder"} + + {:else} +
+ {file.name} +
+ {/if} +
+ {formatSize(file.size_bytes)} • {formatDate( + file.updated_at, + )} +
+
+
+
+
+ {#if file.type === "file" && file.storage_url} + + {/if} + +
+
+
+ +

This folder is empty.

+
+
+ {/if} +
+
+ + diff --git a/src/routes/(app)/notifications/+page.svelte b/src/routes/(app)/notifications/+page.svelte new file mode 100644 index 0000000..7d20391 --- /dev/null +++ b/src/routes/(app)/notifications/+page.svelte @@ -0,0 +1,175 @@ + + + + Notifications — FPMB + + + +
+
+
+

+ Notifications +

+

+ Stay updated on your projects and tasks. +

+
+ +
+ + {#if loading} +

Loading...

+ {:else if notifications.length === 0} +
+ +

All caught up!

+

You have no new notifications.

+
+ {:else} +
+
    + {#each notifications as notification (notification.id)} +
  • +
    +
    +
    + +
    +
    +
    +
    +

    + {labelForType(notification.type)} +

    + {relativeTime(notification.created_at)} +
    +

    + {notification.message} +

    +
    +
    + {#if !notification.read} +
    + + {/if} + +
    +
    +
  • + {/each} +
+
+ {/if} +
diff --git a/src/routes/(app)/projects/+page.svelte b/src/routes/(app)/projects/+page.svelte new file mode 100644 index 0000000..6371836 --- /dev/null +++ b/src/routes/(app)/projects/+page.svelte @@ -0,0 +1,306 @@ + + + + Projects — FPMB + + + +
+
+

Projects

+ +
+ + {#if loading} +

Loading...

+ {:else if error} +

{error}

+ {:else if projects.length === 0} +
+
+ +
+

No projects yet

+

+ Create your first project to get started. +

+ +
+ {:else} +
+ {#each projects as project (project.id)} +
+
+ {project.name} + + {statusLabel(project)} + +
+ {#if project.team_name} +

+ + {project.team_name} +

+ {/if} +

+ {project.description || "No description"} +

+
+
+ Updated {new Date(project.updated_at).toLocaleDateString( + "en-US", + { month: "2-digit", day: "2-digit", year: "numeric" }, + )} +
+
+ + + + {#if !project.team_name} + + + + {/if} + + + +
+
+
+ {/each} +
+ {/if} +
+ +{#if showModal} + +{/if} diff --git a/src/routes/(app)/projects/[id]/calendar/+page.svelte b/src/routes/(app)/projects/[id]/calendar/+page.svelte new file mode 100644 index 0000000..82aff9d --- /dev/null +++ b/src/routes/(app)/projects/[id]/calendar/+page.svelte @@ -0,0 +1,215 @@ + + + + Project Calendar — FPMB + + + +
+
+
+ + + +
+

+ Project Calendar +

+
+ Overview + / +
+
+
+
+ +
+
+ + {#if loading} +
+ Loading events... +
+ {:else} + + {/if} +
+ +{#if isModalOpen} +
+ + +
(isModalOpen = false)}>
+
+
+

Add Event

+ +
+
+ {#if error} +

{error}

+ {/if} +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+{/if} diff --git a/src/routes/(app)/projects/[id]/files/+page.svelte b/src/routes/(app)/projects/[id]/files/+page.svelte new file mode 100644 index 0000000..b2e1989 --- /dev/null +++ b/src/routes/(app)/projects/[id]/files/+page.svelte @@ -0,0 +1,391 @@ + + + + Project Files — FPMB + + + +
+
+
+ + + +
+

+ Project Files +

+
+ + {#each folderStack as crumb, i} + / + + {/each} + / +
+
+
+
+ + + +
+
+ + {#if showFolderInput} +
+ + + +
+ {/if} + +
+ {#if loading} +
Loading files...
+ {:else} + + + + + + + + + + + {#each fileList as file (file.id)} + + file.type === "folder" + ? openFolder(file) + : (viewingFile = file)} + > + + + + + + {/each} + + {#if fileList.length === 0} + + + + {/if} + +
NameActions
+
+
+ {@html getIcon(file.type)} +
+
+ {#if file.type === "folder"} + + {:else} +
+ {file.name} +
+ {/if} +
+ {formatSize(file.size_bytes)} • {formatDate( + file.updated_at, + )} +
+
+
+
+
+ {#if file.type === "file" && file.storage_url} + + {/if} + +
+
+
+ +

This folder is empty.

+
+
+ {/if} +
+
+ + diff --git a/src/routes/(app)/projects/[id]/settings/+page.svelte b/src/routes/(app)/projects/[id]/settings/+page.svelte new file mode 100644 index 0000000..b9923ba --- /dev/null +++ b/src/routes/(app)/projects/[id]/settings/+page.svelte @@ -0,0 +1,234 @@ + + + + {projectName + ? `${projectName} Settings — FPMB` + : "Project Settings — FPMB"} + + + +
+
+ + + +
+

+ Project Settings +

+

+ Configure {projectName || "..."} preferences and access. +

+
+
+ + + + {#if loading} +
Loading...
+ {:else} +
+
+

General Info

+

+ Update project name and description. +

+
+ +
+ {#if saveError} +

{saveError}

+ {/if} + {#if saveSuccess} +

Changes saved.

+ {/if} + +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+
+

Danger Zone

+

+ Irreversible destructive actions. +

+
+ +
+
+
+

Archive Project

+

+ Mark this project as read-only and hide it from the active lists. +

+
+ +
+ +
+
+

Delete Project

+

+ Permanently remove this project, its boards, files, and all + associated data. +

+
+ +
+
+
+ {/if} +
diff --git a/src/routes/(app)/projects/[id]/webhooks/+page.svelte b/src/routes/(app)/projects/[id]/webhooks/+page.svelte new file mode 100644 index 0000000..bfa79f5 --- /dev/null +++ b/src/routes/(app)/projects/[id]/webhooks/+page.svelte @@ -0,0 +1,372 @@ + + + + Webhooks & Integrations — FPMB + + + +
+
+ + + +
+

+ Webhooks & Integrations +

+

+ Connect your project with external tools and services. +

+
+
+ + + +
+
+
+

+ Configured Webhooks +

+

+ Trigger actions in other apps when events occur in FPMB. +

+
+ +
+ + {#if loading} +
Loading webhooks...
+ {:else if webhookList.length === 0} +
+ +

No Webhooks Yet

+

+ Add a webhook to start receiving automated updates in Discord, GitHub, + and more. +

+
+ {:else} +
+ + + + + + + + + + + {#each webhookList as webhook (webhook.id)} + {@const wtype = getWebhookType(webhook.url)} + + + + + + + {/each} + +
IntegrationStatusActions
+
+
+ +
+
+
+ {webhook.name} +
+
+ {wtype} • Last: {formatLastTriggered( + webhook.last_triggered, + )} +
+
+
+
+ + +
+ +
+
+
+ {/if} +
+
+ +{#if isModalOpen} +
+ + +
(isModalOpen = false)}>
+ +
+
+

Add Webhook

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+{/if} diff --git a/src/routes/(app)/settings/user/+page.svelte b/src/routes/(app)/settings/user/+page.svelte new file mode 100644 index 0000000..c61e630 --- /dev/null +++ b/src/routes/(app)/settings/user/+page.svelte @@ -0,0 +1,711 @@ + + + + User Settings — FPMB + + + +
+
+

+ User Settings +

+

Manage your profile and account preferences.

+
+ + +
+
+

Profile Information

+

+ Update your account's profile information and email address. +

+
+ +
+ {#if profileError} +
+ {profileError} +
+ {/if} + {#if profileSuccess} +
+ {profileSuccess} +
+ {/if} + +
+
+ {#if authStore.user?.avatar_url} + Avatar + {:else} +
+ {userInitial} +
+ {/if} +
+
+ {#if avatarError}

+ {avatarError} +

{/if} + {#if avatarSuccess}

+ {avatarSuccess} +

{/if} + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+

Update Password

+

+ Ensure your account is using a long, random password to stay secure. +

+
+ +
+ {#if passwordError} +
+ {passwordError} +
+ {/if} + {#if passwordSuccess} +
+ {passwordSuccess} +
+ {/if} + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+
+

API Keys

+

+ Generate personal API keys with granular scopes for programmatic + access. +

+
+ {#if !showCreateForm} + + {/if} +
+ + + {#if createdKey} +
+
+ + + +
+

+ Key created — copy it now, it won't be shown again. +

+
+ {createdKey.key} + +
+
+ +
+
+ {/if} + + + {#if showCreateForm} +
+
+ + +
+ +
+

+ Scopes ({selectedScopeCount} selected) +

+
+ {#each Object.entries(scopeGroups) as [group, scopes]} +
+

+ {group} +

+
+ {#each scopes as scope} + + {/each} +
+
+ {/each} +
+
+ + {#if newKeyError} +

{newKeyError}

+ {/if} + +
+ + +
+
+ {/if} + + +
+ {#if apiKeysLoading} +
+ Loading keys... +
+ {:else if apiKeyList.length === 0 && !createdKey} +
+ + + +

No API keys yet. Create one above.

+
+ {:else} + {#each apiKeyList as key (key.id)} +
+
+
+ {key.name} + {key.prefix}… +
+
+ {#each key.scopes as scope} + {scope} + {/each} +
+

+ Created {formatDate(key.created_at)}{#if key.last_used} + · Last used {formatDate(key.last_used)}{/if} +

+
+ +
+ {/each} + {/if} +
+
+
diff --git a/src/routes/(app)/team/[id]/+page.svelte b/src/routes/(app)/team/[id]/+page.svelte new file mode 100644 index 0000000..ffbd4cc --- /dev/null +++ b/src/routes/(app)/team/[id]/+page.svelte @@ -0,0 +1,541 @@ + + + + {team ? `${team.name} — FPMB` : "Team — FPMB"} + + + +
+ {#if loading} +

Loading...

+ {:else if team} + +
+
+ {#if team.banner_url} + Team banner + {/if} +
+
+ +
+
+
+ {#if team.avatar_url} + Team avatar + {:else} +
+ {team.name.charAt(0)} +
+ {/if} +
+
+
+

+ {team.name} +

+ {#if myRole > 0} + + {teamRoleName} + + {/if} +
+

+ + {members.length} Members +

+
+
+ +
+ + + Calendar + + + + Docs + + + + Files + + + + Chat + + {#if hasPermission(myRole, RoleFlag.Admin) || hasPermission(myRole, RoleFlag.Owner)} + + + Settings + + {/if} +
+
+
+ + +
+ +
+
+

+ + Active Projects +

+ + View all + +
+
+ {#each recentProjects.slice(0, 4) as project (project.id)} + +
+
+ +
+
+

+ {project.name} +

+

+ + {formatDate(project.updated_at)} +

+
+ {/each} + + {#if canCreate} + + {/if} +
+
+ + +
+
+

+ + Calendar +

+ + + +
+
+ +
+ +
+ + +
+
+

+ + Team Knowledge Base +

+ + + +
+
+ {#if recentDocs.length === 0} +

No docs yet.

+ {:else} +
+ {#each recentDocs.slice(0, 6) as doc (doc.id)} + +
+ +
+
+

+ {doc.title} +

+

+ + {formatDate(doc.updated_at)} +

+
+
+ {/each} +
+ {/if} +
+
+
+ {/if} +
+ +{#if showCreateProject} + +{/if} diff --git a/src/routes/(app)/team/[id]/calendar/+page.svelte b/src/routes/(app)/team/[id]/calendar/+page.svelte new file mode 100644 index 0000000..35b61a1 --- /dev/null +++ b/src/routes/(app)/team/[id]/calendar/+page.svelte @@ -0,0 +1,270 @@ + + + + Team Calendar — FPMB + + + +
+
+
+ + + +
+

+ Team Calendar +

+

+ Team events and task due dates +

+
+
+
+ +
+
+ + {#if loading} +
+ Loading events... +
+ {:else} + + {/if} +
+ +{#if isModalOpen} +
+ + +
(isModalOpen = false)}>
+
+
+

Add Event

+ +
+
+ {#if error} +

{error}

+ {/if} +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+{/if} diff --git a/src/routes/(app)/team/[id]/chat/+page.svelte b/src/routes/(app)/team/[id]/chat/+page.svelte new file mode 100644 index 0000000..5f80462 --- /dev/null +++ b/src/routes/(app)/team/[id]/chat/+page.svelte @@ -0,0 +1,529 @@ + + + + {team ? `Chat — ${team.name}` : "Team Chat"} — FPMB + + + +
+ +
+
+
+ + + +
+ +
+
+

+ {team?.name ?? "Team"} Chat +

+

+ {#if onlineUsers.length > 0} + {onlineUsers.length + 1} members online + {:else} + Just you + {/if} +

+
+
+
+ {#if onlineUsers.length > 0} +
+ {#each onlineUsers.slice(0, 5) as user} +
+ {user.name.charAt(0).toUpperCase()} +
+ {/each} + {#if onlineUsers.length > 5} +
+ +{onlineUsers.length - 5} +
+ {/if} +
+ {/if} +
+
+ + {wsConnected ? "Live" : "Offline"} + +
+
+
+
+ + +
+
+ {#if loading} +
+
+ + Loading messages… +
+
+ {:else if messages.length === 0} +
+
+ +
+

+ Start the conversation +

+

+ Messages are visible to all team members. Say hello! +

+
+ {:else} + {#if loadingMore} +
+ +
+ {/if} + {#if !hasMore} +
+
+ Beginning of conversation +
+
+ {/if} + + {#each messages as msg, idx} + {@const isMe = msg.user_id === myId} + {@const showHeader = shouldShowHeader(idx)} + {@const showDate = shouldShowDate(idx)} + + {#if showDate} +
+
+ {formatDateSeparator(msg.created_at)} +
+
+ {/if} + +
+ {#if showHeader} +
+
+ {msg.user_name.charAt(0).toUpperCase()} +
+ {isMe ? "You" : msg.user_name} + {formatTime(msg.created_at)} +
+ {/if} +
+

+ {msg.content} +

+
+ {formatTime(msg.created_at)} +
+ {/each} + {/if} +
+
+ + + {#if typingNames.length > 0} +
+
+ + + + + + {#if typingNames.length === 1} + {typingNames[0]} is typing… + {:else if typingNames.length === 2} + {typingNames[0]} and + {typingNames[1]} are typing… + {:else} + {typingNames.length} people are typing… + {/if} +
+
+ {/if} + + +
+
+
+
+ +
+ +
+
+
+
diff --git a/src/routes/(app)/team/[id]/docs/+page.svelte b/src/routes/(app)/team/[id]/docs/+page.svelte new file mode 100644 index 0000000..c7207de --- /dev/null +++ b/src/routes/(app)/team/[id]/docs/+page.svelte @@ -0,0 +1,226 @@ + + + + Team Docs — FPMB + + + +
+
+ +
+
+

Team Docs

+ +
+ +
+
    + {#each docs as doc (doc.id)} +
  • + +
  • + {/each} +
+
+
+ + +
+ {#if activeDoc} +
+
+ {#if isEditing} + + {:else} +

+ {activeDoc.title} +

+

+ Last updated {new Date(activeDoc.updated_at).toLocaleDateString( + "en-US", + { month: "2-digit", day: "2-digit", year: "numeric" }, + )} +

+ {/if} +
+
+ {#if isEditing} + + {:else} + + {/if} +
+
+ +
+
+ {#if isEditing} + + {:else} +
+ +
+ {/if} +
+
+ {:else} +
+ +

Select a document or create a new one

+
+ {/if} +
+
+
+ + diff --git a/src/routes/(app)/team/[id]/files/+page.svelte b/src/routes/(app)/team/[id]/files/+page.svelte new file mode 100644 index 0000000..2332b14 --- /dev/null +++ b/src/routes/(app)/team/[id]/files/+page.svelte @@ -0,0 +1,387 @@ + + + + Team Files — FPMB + + + +
+
+
+ + + +
+

+ Team Files +

+
+ + {#each folderStack as crumb, i} + / + + {/each} + / +
+
+
+
+ + + +
+
+ + {#if showFolderInput} +
+ + + +
+ {/if} + +
+ {#if loading} +
Loading files...
+ {:else} + + + + + + + + + + + {#each fileList as file (file.id)} + + file.type === "folder" + ? openFolder(file) + : (viewingFile = file)} + > + + + + + + {/each} + + {#if fileList.length === 0} + + + + {/if} + +
NameActions
+
+
+ {@html getIcon(file.type)} +
+
+ {#if file.type === "folder"} + + {:else} +
+ {file.name} +
+ {/if} +
+ {formatSize(file.size_bytes)} • {formatDate( + file.updated_at, + )} +
+
+
+
+
+ {#if file.type === "file" && file.storage_url} + + {/if} + +
+
+
+ +

This folder is empty.

+
+
+ {/if} +
+
+ + diff --git a/src/routes/(app)/team/[id]/settings/+page.svelte b/src/routes/(app)/team/[id]/settings/+page.svelte new file mode 100644 index 0000000..cf3440e --- /dev/null +++ b/src/routes/(app)/team/[id]/settings/+page.svelte @@ -0,0 +1,364 @@ + + + + {teamName ? `${teamName} Settings — FPMB` : "Team Settings — FPMB"} + + + +
+
+
+

+ Team Settings +

+

Manage your team members and roles.

+
+
+ + +
+
+

General

+

+ Update your team's name and description. +

+
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+

Team Avatar

+

+ Upload a square image to represent your team. +

+
+
+
+ {#if avatarPreview} + Team avatar + {:else} + {team?.name.charAt(0) ?? ""} + {/if} +
+
+ + + +
+
+
+ + +
+
+

Team Banner

+

+ Upload a wide image shown at the top of your team page. +

+
+
+
+ {#if bannerPreview} + Team banner + {:else} + No banner set + {/if} +
+
+ + + +
+
+
+ + +
+
+
+

Members

+

+ Invite new members and manage roles. +

+
+ +
+ + + +
+
+ + {#if error} +
+ {error} +
+ {/if} + +
+ + + + + + + + + + {#each members as member (member.user_id)} + + + + + + {/each} + +
MemberRoleActions
+
+
+ {member.name.charAt(0).toUpperCase()} +
+
+
+ {member.name} +
+
{member.email}
+
+
+
+ + {member.role_name} + + + {#if member.role_flags < RoleFlag.Owner} + + {/if} +
+
+
+
diff --git a/src/routes/(app)/whiteboard/[id]/+page.svelte b/src/routes/(app)/whiteboard/[id]/+page.svelte new file mode 100644 index 0000000..dc60c64 --- /dev/null +++ b/src/routes/(app)/whiteboard/[id]/+page.svelte @@ -0,0 +1,1103 @@ + + + + Whiteboard — FPMB + + + +
+ + +
+ + +
+ + + + + + +
+ + + +
+ + + + {#if currentTool === "text"} +
+ + {/if} + +
+ + + + +
+ + + + +
+ +
+ {#if otherUserCount > 0} +
+
+ {#each remoteUsers.slice(0, 5) as user} +
+ {user.name.charAt(0).toUpperCase()} +
+ {/each} +
+ {otherUserCount} online +
+ {/if} +
+ {#if saving} + + {/if} +
+

Whiteboard

+
+
+ + {#if selectedObj && editingIndex !== null} +
+ {#if selectedObj.type === "text"} + + + {:else} + + + updateObjectColor((e.currentTarget as HTMLInputElement).value)} + class="w-8 h-8 rounded border-0 bg-transparent p-0 cursor-pointer" + /> + {/if} + + +
+ {:else if selectedObj} +
+ + +
+ {/if} + +
+ + {#if showTextInput} + {@const rect = canvasContainer + ? canvasContainer.getBoundingClientRect() + : { width: 0, height: 0 }} + {@const scaleX = canvas ? canvas.width / (rect.width || 1) : 1} + {@const scaleY = canvas ? canvas.height / (rect.height || 1) : 1} + + {/if} +
+
diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte new file mode 100644 index 0000000..0b6f8d3 --- /dev/null +++ b/src/routes/(auth)/+layout.svelte @@ -0,0 +1,15 @@ + + + + + FPMB + + +
+ {@render children()} +
diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte new file mode 100644 index 0000000..d54c287 --- /dev/null +++ b/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,125 @@ + + + + Sign In — FPMB + + + +
+
+

FPMB

+

Sign in to your account

+
+ +
+ {#if error} +
+ {error} +
+ {/if} +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ + +
+ + +
+ +
+ +
+
+ +
+ Don't have an account? + Sign up +
+
diff --git a/src/routes/(auth)/register/+page.svelte b/src/routes/(auth)/register/+page.svelte new file mode 100644 index 0000000..c699771 --- /dev/null +++ b/src/routes/(auth)/register/+page.svelte @@ -0,0 +1,145 @@ + + + + Create Account — FPMB + + + +
+
+

FPMB

+

Create a new account

+
+ +
+ {#if error} +
+ {error} +
+ {/if} +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+ Already have an account? + Sign in +
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..a87445d --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,16 @@ + + + + + FPMB — Free Project Management Boards + + +{@render children()} diff --git a/src/routes/layout.css b/src/routes/layout.css new file mode 100644 index 0000000..73f6334 --- /dev/null +++ b/src/routes/layout.css @@ -0,0 +1,44 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/forms'; +@plugin '@tailwindcss/typography'; + +@theme { + --font-sans: 'JetBrains Mono', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; +} + +@layer base { + @font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/JetBrainsMono-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; + } + + :root { + color-scheme: dark; + } + + body { + @apply bg-neutral-900 text-neutral-50 antialiased h-screen w-screen overflow-hidden; + font-family: 'JetBrains Mono', sans-serif; + } + + /* Force dark mode background and text colors globally */ + * { + @apply border-neutral-700; + } +} + +@layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } +} diff --git a/static/fonts/JetBrainsMono-SemiBold.ttf b/static/fonts/JetBrainsMono-SemiBold.ttf new file mode 100644 index 0000000..a70e69b Binary files /dev/null and b/static/fonts/JetBrainsMono-SemiBold.ttf differ diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..873e3f3 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,16 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', // Needed for SPA-like dynamic routing + precompress: false, + strict: true + }) + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..56f40c7 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,5 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });