diff --git a/README.md b/README.md index c62a4df..ecb542d 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,356 @@ -# Taskly — Monorepo Turborepo (NestJS + NextJS + tRPC) +# 🚀 Taskly — Application Collaborative de Gestion de Projets -Taskly est une application web collaborative de gestion de projets et de tâches **inspirée de Trello** (tableaux → listes → cartes), développée dans le cadre d’un projet d’étude. +
-Le cahier des charges complet est disponible ici : [doc/Cahier des charges — Application Taskly.pdf](). +[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue)](https://www.typescriptlang.org/) +[![NestJS](https://img.shields.io/badge/NestJS-10-red)](https://nestjs.com/) +[![Next.js](https://img.shields.io/badge/Next.js-14-black)](https://nextjs.org/) +[![Firestore](https://img.shields.io/badge/Firestore-NoSQL-orange)](https://firebase.google.com/) +[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) -## Architecture du monorepo +**Une application web collaborative pour gérer vos projets et tâches, inspirée de Trello** -- `apps/web` : **NextJS** (frontend) -- `apps/api` : **NestJS** (backend, monté sur Express) -- `packages/trpc` : **routeur tRPC partagé** (types partagés client/serveur) -- `packages/shared` : **types & modèles** partagés (MVP) +[📖 Documentation](#-documentation) • [🏗️ Architecture](#-architecture) • [🚀 Démarrage](#-démarrage-rapide) • [👥 Équipe](#-équipe) -## Pré-requis +
-- **Node.js 20+** -- **pnpm** (via Corepack recommandé) +--- -## Installation +## 📋 À Propos -À la racine du repo : +**Taskly** est une application web moderne et collaborative pour la gestion de projets et de tâches en équipe. Le projet reproduit les fonctionnalités principales de **Trello** dans une architecture moderne avec un focus sur : + +- ✅ **Type-safety end-to-end** (TypeScript strict + tRPC) +- ✅ **Collaboration temps réel** (Firestore listeners) +- ✅ **Sécurité robuste** (Firebase Auth + permissions granulaires) +- ✅ **Performance** (denormalization, pagination, caching) +- ✅ **UX moderne** (Responsive, Dark mode, Drag & drop) + +### 🎯 Concept + +Taskly permet aux équipes d'organiser leur travail via : +- **Workspaces** : Espaces de travail collaboratifs +- **Boards** : Tableaux kanban +- **Colonnes** : Listes de tâches +- **Tickets** : Cartes avec commentaires, assignations, attachments + +--- + +## 👥 Équipe + +| Nom | Rôle | Responsabilités | +|-----|------|-----------------| +| **Younes Bahri** | Lead Developer | Architecture, Backend, DevOps, Coordination | +| **Schekina Ahounou** | Developer | Frontend, Backend, Firebase, QA | + +**Établissement** : Épita +**Période** : Novembre 2025 - Janvier 2026 +**Contexte** : Projet pédagogique de fin d'études + +--- + +## 🏗️ Architecture + +### Structure du Monorepo + +``` +taskly/ +├── apps/ +│ ├── api/ # Backend NestJS +│ │ ├── src/ +│ │ │ ├── board/ # Gestion des tableaux +│ │ │ ├── ticket/ # Gestion des cartes +│ │ │ ├── user/ # Gestion des utilisateurs +│ │ │ ├── workspace/# Gestion des équipes +│ │ │ └── main.ts # Point d'entrée +│ │ └── package.json +│ │ +│ └── web/ # Frontend Next.js +│ ├── src/ +│ │ ├── app/ # Routes et pages +│ │ ├── auth/ # Authentification +│ │ ├── components/ +│ │ └── lib/ +│ └── package.json +│ +├── packages/ # Packages partagés +│ ├── auth/ # Firebase Auth Guard +│ ├── database/ # Modèles Firestore +│ ├── firebase/ # Firebase config +│ ├── shared/ # Types et permissions +│ ├── trpc/ # Router tRPC +│ └── ui/ # Composants réutilisables +│ +└── doc/ # Documentation +``` + +### Stack Technologique + +**Frontend** : Next.js 14, React 18, TypeScript, Tailwind CSS, tRPC Client + +**Backend** : NestJS, Express, TypeScript, tRPC Server + +**Database** : Firestore (NoSQL), Firebase Realtime, Security Rules + +**Auth** : Firebase Authentication (JWT), Guards NestJS + +**Storage** : Google Cloud Storage (GCS), Signed URLs + +**Testing** : Vitest, Testing Library + +**Monorepo** : Turborepo, pnpm workspaces + +**DevOps** : Docker, Cloud Build, Cloud Run, GitHub + +--- + +## ✨ Fonctionnalités Implémentées + +### ✅ MVP Complet (Livré) + +- **Authentification & Autorisation** + - Firebase Auth (email/password) + - JWT verification côté backend + - Guards NestJS pour protection des routes + - Rôles & permissions granulaires (Owner, Admin, Member) + +- **Gestion des Workspaces** + - Créer/éditer/archiver des workspaces + - Invitation de membres avec tokens sécurisés + - Gestion des rôles + - Historique d'activité + +- **Gestion des Tableaux** + - CRUD boards avec Swagger docs + - Backgrounds personnalisés (couleurs, gradients, images Unsplash) + - Réordonnancement + +- **Gestion des Colonnes** + - CRUD colonnes + - Réordonnancement (order-based) + +- **Gestion des Tickets** + - CRUD tickets + - Drag & drop entre colonnes (transactions Firestore) + - Commentaires avec édition + - Assignations + - Labels par board + - Checklists avec items + - Pièces jointes (GCS avec signed URLs) + - Rappels programmables + - Archivage (soft delete) + +- **Collaboration** + - Temps réel via Firestore listeners + - Système de notifications + - Historique d'activité complet + - Watchers/Observateurs + +- **Documentation** + - Swagger/OpenAPI pour REST API + - tRPC type-safe avec introspection + - 2000+ lignes de documentation technique en français + +--- + +## 🚀 Démarrage Rapide + +### Pré-requis + +- Node.js 20+ LTS +- pnpm 9.x (ou via Corepack) +- Git + +### Installation ```bash +# 1. Cloner le repository +git clone https://github.com/youba/taskly.git +cd taskly + +# 2. Activer pnpm (via Corepack) corepack enable corepack prepare pnpm@9.12.3 --activate + +# 3. Installer les dépendances pnpm install ``` -## Lancer le projet en local - -Dans un terminal à la racine : +### Lancer en Développement ```bash +# Lancer tout (frontend + backend) pnpm dev ``` -### Ports par défaut +Les services démarrent sur : +- Frontend : `http://localhost:3000` +- Backend : `http://localhost:4000` +- API Docs : `http://localhost:4000/docs` (Swagger) +- Health Check : `http://localhost:4000/health` + +### Configuration Optionnelle + +Variables d'environnement : + +```bash +# Backend +PORT=4000 # Port du serveur +CORS_ORIGIN=http://localhost:3000 # Origine CORS +NODE_ENV=development # Environnement + +# Frontend +NEXT_PUBLIC_API_URL=http://localhost:4000 # URL API +``` + +--- + +## 📦 Scripts Disponibles + +### Tests & Qualité + +```bash +# Lancer tous les tests +pnpm test + +# Vérifier les types TypeScript +pnpm run typecheck + +# Linter le code +pnpm lint + +# Mode watch +pnpm test:watch +``` + +### Build & Déploiement + +```bash +# Builder tous les packages +pnpm build + +# Vérifier le build +pnpm run verify + +# Déployer (déclenche Cloud Build) +git push origin main +``` + +--- + +## 📚 Documentation + +Une **documentation complète en français** (2000+ lignes) est disponible dans `doc/` : + +| Document | Contenu | Durée | +|----------|---------|-------| +| [doc/SOMMAIRE.md](./doc/SOMMAIRE.md) | Guide rapide par rôle | 2 min | +| [doc/README.md](./doc/README.md) | Vue d'ensemble | 5 min | +| [doc/01_ARCHITECTURE_GLOBALE.md](./doc/01_ARCHITECTURE_GLOBALE.md) | Architecture complète | 15 min | +| [doc/02_FIRESTORE_ARCHITECTURE.md](./doc/02_FIRESTORE_ARCHITECTURE.md) | Database & Collections | 25 min | +| [doc/03_SYSTEME_PERMISSIONS.md](./doc/03_SYSTEME_PERMISSIONS.md) | Permissions & Sécurité | 20 min | +| [doc/04_GESTION_TICKETS.md](./doc/04_GESTION_TICKETS.md) | Tickets & Opérations | 30 min | +| [doc/05_WORKFLOWS_COLLABORATIONS.md](./doc/05_WORKFLOWS_COLLABORATIONS.md) | Collaboration temps réel | 25 min | +| [doc/CONTRIBUTION_GUIDELINES.md](./doc/CONTRIBUTION_GUIDELINES.md) | Guide de contribution | 15 min | +| [doc/INDEX.md](./doc/INDEX.md) | Index & FAQ complet | Navigation | + +### Chemins de Lecture par Rôle + +- **Frontend Developer** : SOMMAIRE → 01 → 04 → 05 +- **Backend Developer** : SOMMAIRE → 01 → 02 → 03 → 04 +- **DevOps** : SOMMAIRE → 01 (déploiement section) +- **QA / Tester** : SOMMAIRE → 01 → 03 → 04 → 05 + +--- + +## 🤝 Contribuer + +Avant de contribuer, lire [doc/CONTRIBUTION_GUIDELINES.md](./doc/CONTRIBUTION_GUIDELINES.md). + +### Workflow + +1. Fork le repository +2. Créer une branche : `git checkout -b feat/ma-feature` +3. Commiter : `git commit -am 'feat: ajouter ma feature'` +4. Tester : `pnpm test && pnpm run typecheck && pnpm lint` +5. Push : `git push origin feat/ma-feature` +6. Ouvrir une Pull Request + +### Checklist avant PR + +- Tests ajoutés/passants +- Type checking : `pnpm run typecheck` ✅ +- Linting : `pnpm lint` ✅ +- Documentation mise à jour +- Commits au format : `(): ` + +--- + +## 🐛 Signaler un Bug + +Trouvé un bug? Ouvrez une [GitHub Issue](https://github.com/youba/taskly/issues) avec : + +- **Titre** : Description en 1 phrase +- **Description** : Pas à pas pour reproduire +- **Comportement attendu/actuel** : Différence +- **Screenshots** : Si applicable +- **Environnement** : OS, Node version, etc. + +--- + +## 📊 Status du Projet + +### ✅ Complet + +- ✅ Architecture monorepo (Turborepo) +- ✅ Backend NestJS + tRPC + Swagger +- ✅ Frontend Next.js + React +- ✅ Authentification Firebase +- ✅ Permissions & rôles +- ✅ Firestore database (13 collections) +- ✅ Tests unitaires (Vitest) +- ✅ Documentation technique complète +- ✅ API Swagger docs + +### 📋 Potentielles Améliorations + +- Webhooks & intégrations externes +- Slack/Teams integration +- Advanced reporting +- Custom automations +- Mobile app (React Native) -- **Frontend** : `http://localhost:3000` -- **Backend** : `http://localhost:4000` - - Healthcheck : `GET http://localhost:4000/health` - - tRPC : `http://localhost:4000/trpc` +--- -### Configuration (optionnelle) +## 📄 Licence -- **CORS (backend)** : variable `CORS_ORIGIN` (par défaut `http://localhost:3000`) -- **Port backend** : variable `PORT` (par défaut `4000`) -- **URL backend côté frontend** : `NEXT_PUBLIC_API_URL` (par défaut `http://localhost:4000`) +Ce projet est sous licence **MIT**. Voir [LICENSE](LICENSE) pour plus de détails. -> Note : dans cet environnement, la création de fichiers `.env*` peut être bloquée. Vous pouvez donc définir ces variables directement dans votre terminal (PowerShell) ou via la configuration de votre IDE. +--- -## Ce qui est déjà implémenté (MVP technique) +## 📞 Contact & Support -- **Monorepo Turborepo** avec workspaces (`apps/*`, `packages/*`) -- **Backend NestJS** : - - route `GET /health` - - montage tRPC sur `/trpc` -- **Frontend NextJS** : - - provider tRPC + React Query - - page d’accueil qui appelle `hello` (preuve que l’intégration fonctionne) +- **Issues** : [GitHub Issues](https://github.com/youba/taskly/issues) +- **Discussions** : [GitHub Discussions](https://github.com/youba/taskly/discussions) +- **Documentation** : [doc/](./doc/) -## Fonctionnalités attendues (cahier des charges) +--- -Objectif : reproduire les fonctionnalités principales de Trello dans une version personnalisée nommée Taskly. +## 🙏 Remerciements -- **Authentification** : Firebase Auth (email/mot de passe), sécurisation des routes, profil (nom/photo/email) -- **Tableaux** : création/édition/suppression, couleur d’arrière-plan, partage et collaboration -- **Listes** : ajout/renommage/réordonnancement/suppression, drag & drop -- **Cartes** : CRUD + déplacement, description, labels, date limite, pièces jointes, commentaires, “terminée” -- **Collaboration temps réel** : Firestore (updates instantanées), attribution de membres, commentaires/notifications -- **UI** : inspirée Trello, responsive, thème clair/sombre +Merci à : +- **Firebase** pour l'authentification et la database +- **NestJS** pour le framework backend +- **Next.js** pour le framework frontend +- **Turborepo** pour la gestion du monorepo +- **Épita** pour le contexte pédagogique -(Voir le détail complet dans le PDF.) -Source : [Cahier des charges — Application Taskly]() +--- -## Scripts utiles +
-- `pnpm dev` : lance tout en mode développement (turbo) -- `pnpm build` : build de tous les packages/apps -- `pnpm typecheck` : vérifie les types -- `pnpm lint` : lint monorepo +**Fait avec ❤️ par Younes Bahri & Schekina Ahounou** +2025 © Taskly - Tous droits réservés +
diff --git a/apps/api/package.json b/apps/api/package.json index 104574c..f54bf9e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx watch src/main.ts", + "dev": "node --loader ts-node/esm --watch src/main.ts", "build": "tsup", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint .", @@ -17,6 +17,7 @@ "@nestjs/common": "^10.4.8", "@nestjs/core": "^10.4.8", "@nestjs/platform-express": "^10.4.8", + "@nestjs/swagger": "^7.4.2", "@taskly/auth": "workspace:*", "@taskly/database": "workspace:*", "@taskly/firebase": "workspace:*", @@ -27,7 +28,8 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "unsplash-js": "^7.0.20" }, "devDependencies": { "@nestjs/testing": "^10.4.22", @@ -35,6 +37,7 @@ "@types/express": "^4.17.21", "@types/node": "^20.11.30", "@vitest/coverage-v8": "^4.0.16", + "ts-node": "^10.9.2", "tsup": "^8.2.4", "tsx": "^4.19.2", "typescript": "^5.5.4", diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index a13e643..b159565 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,7 +1,35 @@ +/** + * Global application controller providing health check endpoint. + * + * This controller handles basic server health checks and is publicly accessible + * without authentication. Useful for load balancers and monitoring services. + */ + import { Controller, Get } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +/** + * Global application controller. + * Serves root-level endpoints like health checks. + */ +@ApiTags('health') @Controller() export class AppController { + /** + * Health check endpoint. + * Returns a simple success response to indicate API is running. + * + * Public endpoint - no authentication required. + * Used by load balancers and monitoring systems. + * + * @returns Object with ok status + */ + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) @Get('/health') health() { return { ok: true }; diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 691141d..4d2a011 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,3 +1,13 @@ +/** + * Root application module for Taskly API. + * + * Orchestrates all sub-modules and imports shared services: + * - Firebase authentication configuration + * - Database module with ORM models and services + * - Feature modules for Users, Workspaces, Boards, and Tickets + * - Global controllers (/health endpoint) + */ + import { Module } from '@nestjs/common'; import { AppController } from './app.controller.js'; import { FirebaseModule } from '@taskly/firebase'; @@ -9,13 +19,17 @@ import { TicketModule } from './ticket/ticket.module.js'; @Module({ imports: [ + // Firebase authentication and admin SDK configuration FirebaseModule.forRoot(), + // Database models and services (Firestore integration) DatabaseModule, + // Feature modules with controllers and services UserModule, WorkspaceModule, BoardModule, TicketModule, ], + // Global controllers available at app level controllers: [AppController], }) export class AppModule {} diff --git a/apps/api/src/board/board-access.service.ts b/apps/api/src/board/board-access.service.ts index 1865817..2b764b3 100644 --- a/apps/api/src/board/board-access.service.ts +++ b/apps/api/src/board/board-access.service.ts @@ -1,8 +1,21 @@ +/** + * Board Access Control Service + * + * Provides authorization checks for board operations. + * Verifies board existence, user workspace membership, and enforces role-based permissions. + * + * Used by BoardController to ensure users have proper access to board resources. + */ + import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import type { BoardModel, WorkspaceRole } from '@taskly/database'; import { BoardsService, WorkspacesService } from '@taskly/database'; import { boardRolePermissions, hasPermission } from '@taskly/shared'; +/** + * Service for board-level access control. + * Combines board and workspace checks with permission verification. + */ @Injectable() export class BoardAccessService { constructor( @@ -10,28 +23,65 @@ export class BoardAccessService { private readonly workspaces: WorkspacesService, ) {} + /** + * Retrieves a board by ID or throws an error if not found or archived. + * @param boardId - The board ID + * @returns The board model + * @throws NotFoundException if board doesn't exist or is archived + */ async getBoardOrThrow(boardId: string): Promise { const b = await this.boards.getBoardById(boardId); if (!b || b.isArchived) throw new NotFoundException('Board not found'); return b; } + /** + * Retrieves a user's workspace role or throws an error if not a member. + * @param userId - The user ID + * @param workspaceId - The workspace ID + * @returns The user's role in the workspace + * @throws ForbiddenException if user is not a workspace member + */ async getWorkspaceRoleOrThrow(userId: string, workspaceId: string): Promise { const member = await this.workspaces.getMember(workspaceId, userId); if (!member) throw new ForbiddenException('Not a workspace member'); return member.role; } + /** + * Verifies that a role has a required permission. + * Throws if permission check fails. + * + * @param role - The user's workspace role + * @param required - The required permission string (e.g., 'board.meta.write') + * @throws ForbiddenException if permission is not granted + */ requirePermission(role: WorkspaceRole, required: string): void { const grants = boardRolePermissions[role] ?? []; if (!hasPermission(grants, required)) throw new ForbiddenException('Missing permission'); } + /** + * Checks if a role has a required permission without throwing. + * Useful for conditional logic (e.g., hiding/showing fields). + * + * @param role - The user's workspace role + * @param required - The required permission string + * @returns true if permission is granted, false otherwise + */ has(role: WorkspaceRole, required: string): boolean { const grants = boardRolePermissions[role] ?? []; return hasPermission(grants, required); } + /** + * Validates that an ID is not empty or whitespace-only. + * Useful for defensive parameter validation. + * + * @param id - The ID to validate + * @param label - Human-readable field name for error messages (default 'id') + * @throws BadRequestException if ID is empty or whitespace + */ assertNonEmptyId(id: string, label = 'id') { if (!id || !id.trim()) throw new BadRequestException(`Missing ${label}`); } diff --git a/apps/api/src/board/board-backgrounds.service.spec.ts b/apps/api/src/board/board-backgrounds.service.spec.ts new file mode 100644 index 0000000..5ced66f --- /dev/null +++ b/apps/api/src/board/board-backgrounds.service.spec.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BoardBackgroundsService } from './board-backgrounds.service.js'; +import { createApi } from 'unsplash-js'; + +vi.mock('unsplash-js', () => ({ createApi: vi.fn() })); + +describe('BoardBackgroundsService', () => { + const createApiMock = createApi as unknown as ReturnType; + + beforeEach(() => { + createApiMock.mockReset(); + delete process.env.UNSPLASH_ACCESS_KEY; + }); + + it('paginates colors list', async () => { + const service = new BoardBackgroundsService(); + + const first = await service.list({ type: 'color', limit: 3 }); + const second = await service.list({ type: 'color', limit: 3, cursor: first.nextCursor }); + + expect(first.items).toHaveLength(3); + expect(first.items[0]).toMatchObject({ type: 'color' }); + expect(first.nextCursor).toBe('3'); + expect(second.items[0]).toMatchObject({ type: 'color' }); + expect(second.items[0]).not.toEqual(first.items[0]); + }); + + it('paginates gradients list', async () => { + const service = new BoardBackgroundsService(); + + const first = await service.list({ type: 'gradient', limit: 2 }); + const second = await service.list({ type: 'gradient', limit: 2, cursor: first.nextCursor }); + + expect(first.items).toHaveLength(2); + expect(first.items[0]).toMatchObject({ type: 'gradient' }); + expect(first.nextCursor).toBe('2'); + expect(second.items[0]).toMatchObject({ type: 'gradient' }); + expect(second.items[0]).not.toEqual(first.items[0]); + }); + + it('throws if Unsplash access key is missing', async () => { + const service = new BoardBackgroundsService(); + await expect(service.list({ type: 'image', limit: 2 })).rejects.toThrow(/Unsplash access key/i); + }); + + it('lists Unsplash images', async () => { + process.env.UNSPLASH_ACCESS_KEY = 'test-key'; + const list = vi.fn().mockResolvedValue({ + type: 'success', + response: [ + { + id: 'p1', + urls: { regular: 'https://img/regular', small: 'https://img/small' }, + user: { name: 'Alice', links: { html: 'https://unsplash.com/@alice' } }, + color: '#ffffff', + blur_hash: 'hash', + }, + ], + }); + + createApiMock.mockReturnValueOnce({ + photos: { list }, + search: { getPhotos: vi.fn() }, + }); + + const service = new BoardBackgroundsService(); + const res = await service.list({ type: 'image', limit: 1 }); + + expect(list).toHaveBeenCalledWith({ page: 1, perPage: 1 }); + expect(res.items).toHaveLength(1); + expect(res.items[0]).toMatchObject({ + type: 'image', + value: { + source: 'unsplash', + id: 'p1', + url: 'https://img/regular', + thumbUrl: 'https://img/small', + authorName: 'Alice', + authorUrl: 'https://unsplash.com/@alice', + }, + }); + }); +}); + diff --git a/apps/api/src/board/board-backgrounds.service.ts b/apps/api/src/board/board-backgrounds.service.ts new file mode 100644 index 0000000..7d7a296 --- /dev/null +++ b/apps/api/src/board/board-backgrounds.service.ts @@ -0,0 +1,256 @@ +/** + * Board Backgrounds Service + * + * Provides a catalog of board background options: + * - Solid colors (predefined palette) + * - Gradients (predefined gradients) + * - Unsplash photos (dynamically fetched) + * + * Supports pagination via cursor for background browsing. + * Integrates with Unsplash API for stock photography. + */ + +import { Injectable } from '@nestjs/common'; +import { createApi } from 'unsplash-js'; +import type { BoardBackground } from '@taskly/database'; + +/** Union type for background source types */ +type BackgroundType = 'color' | 'gradient' | 'image'; + +/** Input parameters for listing backgrounds */ +export type ListBackgroundsInput = { + /** Type of background to list: color, gradient, or image */ + type: BackgroundType; + /** Maximum number of items to return (clamped to 1-50) */ + limit: number; + /** Pagination cursor from previous request */ + cursor?: string | null; + /** Search query for images (only used when type='image') */ + query?: string | null; +}; + +/** Result of listing backgrounds with pagination info */ +export type ListBackgroundsResult = { + /** Array of background objects */ + items: BoardBackground[]; + /** Cursor for fetching next page (null if no more results) */ + nextCursor: string | null; +}; + +/** + * Predefined color palette for board backgrounds. + * Includes neutral, cool, and warm tones. + */ +const COLOR_VALUES: string[] = [ + '#0F172A', + '#1E293B', + '#334155', + '#0F766E', + '#0EA5E9', + '#2563EB', + '#4338CA', + '#7C3AED', + '#C026D3', + '#DB2777', + '#E11D48', + '#F97316', + '#F59E0B', + '#84CC16', + '#22C55E', + '#14B8A6', +]; + +/** + * Predefined gradient palette for board backgrounds. + * Includes directional gradients with complementary colors. + */ +const GRADIENT_VALUES: string[] = [ + 'linear-gradient(135deg, #0EA5E9 0%, #6366F1 100%)', + 'linear-gradient(135deg, #22C55E 0%, #14B8A6 100%)', + 'linear-gradient(135deg, #F97316 0%, #F59E0B 100%)', + 'linear-gradient(135deg, #DB2777 0%, #7C3AED 100%)', + 'linear-gradient(135deg, #0F766E 0%, #22C55E 100%)', + 'linear-gradient(135deg, #1E293B 0%, #0F172A 100%)', + 'linear-gradient(135deg, #8B5CF6 0%, #EC4899 100%)', + 'linear-gradient(135deg, #38BDF8 0%, #6366F1 100%)', + 'linear-gradient(135deg, #F43F5E 0%, #F97316 100%)', + 'linear-gradient(135deg, #A855F7 0%, #3B82F6 100%)', +]; + +/** + * Clamps limit value to valid range (1-50). + * @param value - The limit to clamp + * @returns Clamped value between 1 and 50 + */ +function clampLimit(value: number): number { + const v = Math.floor(Number.isFinite(value) ? value : 20); + return Math.max(1, Math.min(50, v)); +} + +/** + * Parses a cursor string into a numeric offset. + * @param cursor - The cursor string from pagination + * @param fallback - Default value if cursor is invalid (default 0) + * @returns Numeric offset or fallback value + */ +function parseOffset(cursor?: string | null, fallback = 0): number { + if (!cursor) return fallback; + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return parsed; +} + +/** + * Wraps a color value in BoardBackground type. + * @param value - Hex color code + * @returns BoardBackground with color type + */ +function mapColor(value: string): BoardBackground { + return { type: 'color', value }; +} + +/** + * Wraps a gradient value in BoardBackground type. + * @param value - CSS gradient string + * @returns BoardBackground with gradient type + */ +function mapGradient(value: string): BoardBackground { + return { type: 'gradient', value }; +} + +/** + * Extracts and validates image data from Unsplash API response. + * Safely handles missing/malformed Unsplash response data. + * + * @param photo - Unsplash photo object from API + * @returns BoardBackground with image type, or null if data is invalid + */ +function mapUnsplashPhoto(photo: unknown): BoardBackground | null { + if (!photo || typeof photo !== 'object') return null; + const data = photo as Record; + const id = typeof data.id === 'string' ? data.id : ''; + const urls = (data.urls as Record | undefined) ?? {}; + const url = + (typeof urls.full === 'string' && urls.full) || + (typeof urls.regular === 'string' && urls.regular) || + (typeof urls.raw === 'string' && urls.raw) || + ''; + const thumbUrl = + (typeof urls.small === 'string' && urls.small) || + (typeof urls.thumb === 'string' && urls.thumb) || + (typeof urls.regular === 'string' && urls.regular) || + url || + ''; + if (!id || !url || !thumbUrl) return null; + + const user = (data.user as Record | undefined) ?? {}; + const links = (user.links as Record | undefined) ?? {}; + + return { + type: 'image', + value: { + source: 'unsplash', + id, + url, + thumbUrl, + blurHash: typeof data.blur_hash === 'string' ? data.blur_hash : null, + color: typeof data.color === 'string' ? data.color : null, + authorName: typeof user.name === 'string' ? user.name : null, + authorUrl: typeof links.html === 'string' ? links.html : null, + }, + }; +} + +/** + * Service for managing board background options. + * Provides access to color, gradient, and image backgrounds via pagination. + */ +@Injectable() +export class BoardBackgroundsService { + /** Unsplash API client (initialized if API key is configured) */ + private readonly unsplash = process.env.UNSPLASH_ACCESS_KEY + ? createApi({ accessKey: process.env.UNSPLASH_ACCESS_KEY }) + : null; + + /** + * Lists background options of the specified type. + * Supports pagination via cursor for colors, gradients, and Unsplash images. + * + * For colors and gradients, pagination uses numeric offsets. + * For images (Unsplash), pagination uses page numbers. + * + * @param input - Listing parameters (type, limit, cursor, query) + * @returns Array of backgrounds for current page with nextCursor for pagination + * @throws Error if Unsplash access key not configured when requesting images + * @throws Error if Unsplash API request fails + * + * @example + * // List predefined colors + * const { items, nextCursor } = await service.list({ + * type: 'color', + * limit: 10 + * }); + * + * @example + * // Search Unsplash for landscape photos + * const result = await service.list({ + * type: 'image', + * limit: 12, + * query: 'nature' + * }); + */ + async list(input: ListBackgroundsInput): Promise { + const limit = clampLimit(input.limit); + + // Serve predefined colors with offset-based pagination + if (input.type === 'color') { + const offset = parseOffset(input.cursor, 0); + const slice = COLOR_VALUES.slice(offset, offset + limit); + const nextCursor = offset + limit < COLOR_VALUES.length ? String(offset + limit) : null; + return { items: slice.map(mapColor), nextCursor }; + } + + // Serve predefined gradients with offset-based pagination + if (input.type === 'gradient') { + const offset = parseOffset(input.cursor, 0); + const slice = GRADIENT_VALUES.slice(offset, offset + limit); + const nextCursor = offset + limit < GRADIENT_VALUES.length ? String(offset + limit) : null; + return { items: slice.map(mapGradient), nextCursor }; + } + + // Serve Unsplash images (requires configured API key) + if (!this.unsplash) { + throw new Error('Unsplash access key not configured'); + } + + const page = Math.max(1, parseOffset(input.cursor, 1)); + const query = (input.query ?? '').trim(); + + // Search mode: query-based search with pagination + if (query) { + const res = await this.unsplash.search.getPhotos({ + query, + page, + perPage: limit, + orientation: 'landscape', + }); + if (res.type !== 'success') throw new Error('Unsplash search failed'); + const items = res.response.results.map(mapUnsplashPhoto).filter(Boolean) as BoardBackground[]; + const hasMore = page < res.response.total_pages; + return { items, nextCursor: hasMore ? String(page + 1) : null }; + } + + // Browse mode: curated photos list + const res = await this.unsplash.photos.list({ + page, + perPage: limit, + }); + if (res.type !== 'success') throw new Error('Unsplash list failed'); + const raw = + Array.isArray(res.response) ? res.response : (res.response as { results?: unknown[] } | null)?.results ?? []; + const items = raw.map(mapUnsplashPhoto).filter(Boolean) as BoardBackground[]; + const hasMore = items.length === limit; + return { items, nextCursor: hasMore ? String(page + 1) : null }; + } +} + diff --git a/apps/api/src/board/board.controller.ts b/apps/api/src/board/board.controller.ts index b4be734..ab2ce74 100644 --- a/apps/api/src/board/board.controller.ts +++ b/apps/api/src/board/board.controller.ts @@ -10,21 +10,259 @@ import { Post, UseGuards, } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOkResponse, ApiProperty, ApiTags } from '@nestjs/swagger'; import { CurrentUser, FirebaseAuthGuard } from '@taskly/auth'; -import type { UserModel } from '@taskly/database'; +import type { BoardBackground, UserModel } from '@taskly/database'; import { BoardsService } from '@taskly/database'; import { BoardAccessService } from './board-access.service.js'; -type PatchBoardDto = { title?: string; description?: string; background?: string | null }; -type CreateColumnDto = { title?: string; key?: string }; -type PatchColumnDto = { title?: string; key?: string }; -type ReorderColumnsDto = { columnIds?: string[] }; -type MoveTicketDto = { columnId?: string; position?: number }; -type CreateLabelDto = { name?: string; color?: string | null }; -type PatchLabelDto = { name?: string; color?: string | null }; -type ReorderLabelsDto = { labelIds?: string[] }; +class BoardStatsDto { + @ApiProperty({ example: 5, nullable: true }) + columnsCount!: number | null; + + @ApiProperty({ example: 42, nullable: true }) + ticketsCount!: number | null; +} + +class BoardDto { + @ApiProperty({ example: 'board_123' }) + id!: string; + + @ApiProperty({ example: 'ws_123' }) + workspaceId!: string; + + @ApiProperty({ example: 'Roadmap' }) + title!: string; + + @ApiProperty({ example: 'Product roadmap' }) + description!: string; + + @ApiProperty({ type: 'object', nullable: true }) + background!: BoardBackground | null; + + @ApiProperty({ example: 1 }) + order!: number; + + @ApiProperty({ example: false }) + isArchived!: boolean; + + @ApiProperty({ example: null, nullable: true }) + archivedAt!: string | null; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class BoardDetailsDto { + @ApiProperty({ example: 'board_123' }) + id!: string; + + @ApiProperty({ example: 'ws_123' }) + workspaceId!: string; + + @ApiProperty({ example: 'Roadmap' }) + title!: string; + + @ApiProperty({ example: 'Product roadmap' }) + description!: string; + + @ApiProperty({ type: 'object', nullable: true }) + background!: BoardBackground | null; + + @ApiProperty({ example: 1 }) + order!: number; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; + + @ApiProperty({ type: BoardStatsDto }) + stats!: BoardStatsDto; +} + +class BoardColumnDto { + @ApiProperty({ example: 'col_123' }) + id!: string; + + @ApiProperty({ example: 'board_123' }) + boardId!: string; + + @ApiProperty({ example: 'Todo' }) + title!: string; + + @ApiProperty({ example: 'todo' }) + key!: string; + + @ApiProperty({ example: 0 }) + position!: number; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class BoardLabelDto { + @ApiProperty({ example: 'label_123' }) + id!: string; + + @ApiProperty({ example: 'board_123' }) + boardId!: string; + + @ApiProperty({ example: 'Urgent' }) + name!: string; + + @ApiProperty({ example: '#FF0000' }) + color!: string; + + @ApiProperty({ example: 0 }) + position!: number; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class TicketListItemDto { + @ApiProperty({ example: 'ticket_123' }) + id!: string; + + @ApiProperty({ example: 'board_123' }) + boardId!: string; + + @ApiProperty({ example: 'col_123' }) + columnId!: string; + + @ApiProperty({ example: 'Fix login' }) + title!: string; + + @ApiProperty({ required: false, nullable: true, example: 'Details' }) + description?: string; + + @ApiProperty({ type: [String], example: ['label_1', 'label_2'] }) + labelIds!: string[]; + + @ApiProperty({ example: 0 }) + position!: number; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class PatchBoardDto { + @ApiProperty({ required: false, example: 'Roadmap' }) + title?: string; + + @ApiProperty({ required: false, example: 'Product roadmap' }) + description?: string; + + @ApiProperty({ required: false, type: 'object', nullable: true }) + background?: BoardBackground | string | null; +} + +class CreateColumnDto { + @ApiProperty({ required: false, example: 'Todo' }) + title?: string; + + @ApiProperty({ required: false, example: 'todo' }) + key?: string; +} + +class PatchColumnDto { + @ApiProperty({ required: false, example: 'Todo' }) + title?: string; + + @ApiProperty({ required: false, example: 'todo' }) + key?: string; +} + +class ReorderColumnsDto { + @ApiProperty({ required: false, type: [String], example: ['col_1', 'col_2'] }) + columnIds?: string[]; +} + +class MoveTicketDto { + @ApiProperty({ required: false, example: 'col_123' }) + columnId?: string; + + @ApiProperty({ required: false, example: 3 }) + position?: number; +} + +class CreateLabelDto { + @ApiProperty({ required: false, example: 'Urgent' }) + name?: string; + + @ApiProperty({ required: false, example: '#FF0000', nullable: true }) + color?: string | null; +} + +class PatchLabelDto { + @ApiProperty({ required: false, example: 'Urgent' }) + name?: string; + + @ApiProperty({ required: false, example: '#FF0000', nullable: true }) + color?: string | null; +} + +class ReorderLabelsDto { + @ApiProperty({ required: false, type: [String], example: ['label_1', 'label_2'] }) + labelIds?: string[]; +} + +function parseBoardBackgroundInput(input: unknown): BoardBackground | null { + if (input === null || input === undefined) return null; + if (typeof input === 'string') { + const value = input.trim(); + if (!value) return null; + return { type: 'color', value }; + } + if (typeof input !== 'object') return null; + const data = input as { type?: unknown; value?: unknown }; + if (data.type === 'color' || data.type === 'gradient') { + if (typeof data.value !== 'string') return null; + const value = data.value.trim(); + if (!value) return null; + return { type: data.type, value }; + } + if (data.type === 'image') { + if (typeof data.value !== 'object' || !data.value) return null; + const value = data.value as Record; + const id = typeof value.id === 'string' ? value.id : ''; + const url = typeof value.url === 'string' ? value.url : ''; + const thumbUrl = typeof value.thumbUrl === 'string' ? value.thumbUrl : ''; + if (!id || !url || !thumbUrl) return null; + return { + type: 'image', + value: { + source: value.source === 'unsplash' ? 'unsplash' : 'unsplash', + id, + url, + thumbUrl, + blurHash: typeof value.blurHash === 'string' ? value.blurHash : null, + color: typeof value.color === 'string' ? value.color : null, + authorName: typeof value.authorName === 'string' ? value.authorName : null, + authorUrl: typeof value.authorUrl === 'string' ? value.authorUrl : null, + }, + }; + } + return null; +} @UseGuards(FirebaseAuthGuard) +@ApiTags('boards') +@ApiBearerAuth('bearer') @Controller('/api/boards') export class BoardController { constructor( @@ -33,6 +271,7 @@ export class BoardController { ) {} @Get('/:boardId') + @ApiOkResponse({ type: BoardDetailsDto }) async getBoard(@CurrentUser() user: UserModel, @Param('boardId') boardId: string) { const board = await this.access.getBoardOrThrow(boardId); const role = await this.access.getWorkspaceRoleOrThrow(user.id, board.workspaceId); @@ -63,6 +302,8 @@ export class BoardController { } @Patch('/:boardId') + @ApiBody({ type: PatchBoardDto }) + @ApiOkResponse({ type: BoardDto }) async patchBoard( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, @@ -72,20 +313,29 @@ export class BoardController { const role = await this.access.getWorkspaceRoleOrThrow(user.id, board.workspaceId); this.access.requirePermission(role, 'board.meta.write'); - const patch: { title?: string; description?: string; background?: string | null } = {}; + const patch: { title?: string; description?: string; background?: BoardBackground | null } = {}; if (typeof body.title === 'string') { const t = body.title.trim(); if (!t) throw new BadRequestException('title cannot be empty'); patch.title = t; } if (typeof body.description === 'string') patch.description = body.description.trim(); - if (body.background === null || typeof body.background === 'string') patch.background = body.background; + if (Object.prototype.hasOwnProperty.call(body, 'background')) { + if (body.background === null) { + patch.background = null; + } else { + const parsed = parseBoardBackgroundInput(body.background); + if (!parsed) throw new BadRequestException('background is invalid'); + patch.background = parsed; + } + } return await this.boards.updateBoard(boardId, patch); } // Columns @Get('/:boardId/columns') + @ApiOkResponse({ type: [BoardColumnDto] }) async listColumns(@CurrentUser() user: UserModel, @Param('boardId') boardId: string) { const board = await this.access.getBoardOrThrow(boardId); const role = await this.access.getWorkspaceRoleOrThrow(user.id, board.workspaceId); @@ -94,6 +344,8 @@ export class BoardController { } @Post('/:boardId/columns') + @ApiBody({ type: CreateColumnDto }) + @ApiOkResponse({ type: BoardColumnDto }) async createColumn( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, @@ -112,6 +364,8 @@ export class BoardController { } @Patch('/:boardId/columns/:columnId') + @ApiBody({ type: PatchColumnDto }) + @ApiOkResponse({ type: BoardColumnDto }) async patchColumn( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, @@ -137,6 +391,12 @@ export class BoardController { } @Delete('/:boardId/columns/:columnId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async deleteColumn( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, @@ -154,6 +414,13 @@ export class BoardController { } @Patch('/:boardId/columns/order') + @ApiBody({ type: ReorderColumnsDto }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async reorderColumns( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, @@ -176,6 +443,7 @@ export class BoardController { // Labels @Get('/:boardId/labels') + @ApiOkResponse({ type: [BoardLabelDto] }) async listLabels(@CurrentUser() user: UserModel, @Param('boardId') boardId: string) { const board = await this.access.getBoardOrThrow(boardId); const role = await this.access.getWorkspaceRoleOrThrow(user.id, board.workspaceId); @@ -184,6 +452,8 @@ export class BoardController { } @Post('/:boardId/labels') + @ApiBody({ type: CreateLabelDto }) + @ApiOkResponse({ type: BoardLabelDto }) async createLabel(@CurrentUser() user: UserModel, @Param('boardId') boardId: string, @Body() body: CreateLabelDto) { const board = await this.access.getBoardOrThrow(boardId); const role = await this.access.getWorkspaceRoleOrThrow(user.id, board.workspaceId); @@ -208,6 +478,13 @@ export class BoardController { } @Patch('/:boardId/labels/order') + @ApiBody({ type: ReorderLabelsDto }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async reorderLabels( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, @@ -230,6 +507,8 @@ export class BoardController { } @Patch('/:boardId/labels/:labelId') + @ApiBody({ type: PatchLabelDto }) + @ApiOkResponse({ type: BoardLabelDto }) async patchLabel( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, @@ -259,6 +538,12 @@ export class BoardController { } @Delete('/:boardId/labels/:labelId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async deleteLabel(@CurrentUser() user: UserModel, @Param('boardId') boardId: string, @Param('labelId') labelId: string) { const board = await this.access.getBoardOrThrow(boardId); const role = await this.access.getWorkspaceRoleOrThrow(user.id, board.workspaceId); @@ -269,6 +554,7 @@ export class BoardController { // Tickets @Get('/:boardId/tickets') + @ApiOkResponse({ type: [TicketListItemDto] }) async listTickets(@CurrentUser() user: UserModel, @Param('boardId') boardId: string) { const board = await this.access.getBoardOrThrow(boardId); const role = await this.access.getWorkspaceRoleOrThrow(user.id, board.workspaceId); @@ -290,6 +576,7 @@ export class BoardController { } @Get('/:boardId/columns/:columnId/tickets') + @ApiOkResponse({ type: [TicketListItemDto] }) async listTicketsByColumn( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, @@ -315,6 +602,13 @@ export class BoardController { } @Patch('/:boardId/tickets/:ticketId/move') + @ApiBody({ type: MoveTicketDto }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async moveTicket( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, @@ -340,6 +634,12 @@ export class BoardController { } @Delete('/:boardId/tickets/:ticketId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async deleteTicket( @CurrentUser() user: UserModel, @Param('boardId') boardId: string, diff --git a/apps/api/src/board/board.module.ts b/apps/api/src/board/board.module.ts index b26e337..8511ea3 100644 --- a/apps/api/src/board/board.module.ts +++ b/apps/api/src/board/board.module.ts @@ -1,10 +1,32 @@ +/** + * Board Module + * + * Provides REST endpoints for board management and kanban operations. + * + * Features: + * - Board CRUD (create, read, update, archive) + * - Column management (create, reorder, delete) + * - Label/tag management with colors + * - Ticket management (list, create, move between columns, archive) + * - Background customization (colors, gradients, Unsplash images) + * - Permission checking via BoardAccessService + * + * All endpoints require Firebase authentication. + * Access is controlled by workspace membership and board-level permissions. + * + * Related Services: + * - BoardAccessService: Authorization and permission checks + * - BoardBackgroundsService: Background options catalog + */ + import { Module } from '@nestjs/common'; import { BoardController } from './board.controller.js'; import { BoardAccessService } from './board-access.service.js'; +import { BoardBackgroundsService } from './board-backgrounds.service.js'; @Module({ controllers: [BoardController], - providers: [BoardAccessService], + providers: [BoardAccessService, BoardBackgroundsService], }) export class BoardModule {} diff --git a/apps/api/src/gcs/gcs.service.ts b/apps/api/src/gcs/gcs.service.ts index 15a1cb6..7717a38 100644 --- a/apps/api/src/gcs/gcs.service.ts +++ b/apps/api/src/gcs/gcs.service.ts @@ -1,30 +1,68 @@ +/** + * Google Cloud Storage (GCS) Service + * + * Manages signed URLs for secure file uploads and downloads. + * Supports both simple PUT uploads and resumable uploads for large files. + * + * Files are stored in hierarchical paths: + * - Board attachments: boards/{boardId}/tickets/{ticketId}/{randomHex}-{filename} + * - User avatars: users/{userId}/avatar/{randomHex}-{filename} + */ + import { Injectable } from '@nestjs/common'; import { Storage } from '@google-cloud/storage'; import path from 'node:path'; +/** + * Result of a signed URL generation. + * Contains URL, HTTP method, required headers, and expiration time. + */ type SignedUrlResult = { + /** The signed URL to use for the operation */ url: string; + /** HTTP method to use: PUT for write, POST for resumable start, GET for read */ method: 'PUT' | 'GET' | 'POST'; + /** Required headers for the signed URL request */ headers: Record; + /** ISO timestamp when the signed URL expires */ expiresAt: string; + /** Type of operation: write for direct PUT, resumable for session-based, read for GET */ type: 'write' | 'resumable' | 'read'; }; +/** + * Calculates ISO timestamp for given minutes in the future. + * @param minutes - Number of minutes to add to current time + * @returns ISO 8601 formatted string + */ function addMinutesIso(minutes: number): string { const d = new Date(Date.now() + minutes * 60_000); return d.toISOString(); } +/** + * Service for managing Google Cloud Storage operations. + * Provides signed URL generation for secure file uploads/downloads. + */ @Injectable() export class GcsService { private readonly storage: Storage; + /** + * Initializes GCS client with credentials from environment. + * Uses GOOGLE_APPLICATION_CREDENTIALS if available, otherwise uses Application Default Credentials. + */ constructor() { const keyFilename = (process.env.GOOGLE_APPLICATION_CREDENTIALS ?? '').trim(); // Force using the provided key file when set (avoids falling back to "authorized_user" ADC locally). this.storage = keyFilename ? new Storage({ keyFilename }) : new Storage(); } + /** + * Retrieves the GCS bucket name from environment variables. + * Checks multiple environment variable names for flexibility. + * @returns Bucket name or empty string if not configured + */ private getBucketName(): string { return ( process.env.GCS_BUCKET ?? @@ -35,12 +73,48 @@ export class GcsService { ).trim(); } + /** + * Gets the GCS bucket instance. + * @returns GCS bucket reference + * @throws Error if bucket name is not configured + */ private bucket() { const name = this.getBucketName(); if (!name) throw new Error('GCS bucket not configured (set GCS_BUCKET)'); return this.storage.bucket(name); } + /** + * Generates a signed upload URL for a file in GCS. + * Supports both simple PUT uploads and resumable uploads (for large files). + * + * Resumable uploads are recommended for files > 5MB as they can be paused/resumed. + * + * @param input - Upload configuration + * @param input.objectPath - GCS object path (e.g., boards/{id}/tickets/{id}/file.pdf) + * @param input.contentType - MIME type of the file + * @param input.expiresInMinutes - URL expiration time in minutes (default 15) + * @param input.resumable - Use resumable upload protocol (default true) + * @returns Signed URL details with HTTP method and required headers + * @throws Error if GCS bucket is not configured or credentials are invalid + * + * @example + * // Simple upload + * const { url, method, headers } = await gcsService.signedUploadUrl({ + * objectPath: 'users/user123/avatar/randomhex-profile.jpg', + * contentType: 'image/jpeg', + * resumable: false + * }); + * + * @example + * // Resumable upload for large files + * const result = await gcsService.signedUploadUrl({ + * objectPath: 'boards/board1/tickets/ticket1/large-file.zip', + * contentType: 'application/zip', + * resumable: true + * }); + * // Client must POST with 'x-goog-resumable: start' header to get session URL + */ async signedUploadUrl(input: { objectPath: string; contentType: string; expiresInMinutes?: number; resumable?: boolean }): Promise { try { const expiresInMinutes = input.expiresInMinutes ?? 15; @@ -86,6 +160,22 @@ export class GcsService { } } + /** + * Generates a signed download URL for a file in GCS. + * Allows temporary public read access to private GCS objects. + * + * @param input - Download configuration + * @param input.objectPath - GCS object path to download + * @param input.expiresInMinutes - URL expiration time in minutes (default 15) + * @returns Signed URL details with GET method and no special headers + * @throws Error if GCS bucket is not configured or credentials are invalid + * + * @example + * const { url } = await gcsService.signedDownloadUrl({ + * objectPath: 'boards/board1/tickets/ticket1/randomhex-attachment.pdf' + * }); + * // Share URL with client - user can download until URL expires + */ async signedDownloadUrl(input: { objectPath: string; expiresInMinutes?: number }): Promise { try { const expiresInMinutes = input.expiresInMinutes ?? 15; @@ -110,12 +200,21 @@ export class GcsService { } } + /** + * Deletes an object from GCS. + * Performs best-effort deletion - does not throw if object doesn't exist. + * + * @param objectPath - GCS object path to delete + * + * @example + * await gcsService.deleteObject('boards/board1/tickets/ticket1/randomhex-file.pdf'); + */ async deleteObject(objectPath: string): Promise { const file = this.bucket().file(objectPath); try { await file.delete({ ignoreNotFound: true }); } catch { - // best-effort + // best-effort - don't fail if deletion has issues } } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index d391466..d60b82d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,21 @@ +/** + * Taskly API Entry Point + * + * This is the main bootstrap file for the Taskly API server. + * It initializes: + * - Express server with CORS configuration + * - NestJS application with Swagger documentation + * - Firebase authentication and tRPC middleware + * - Database services and Google Cloud Storage integration + * + * The API serves both REST endpoints (via NestJS controllers) + * and tRPC procedures mounted at /trpc. + */ + import 'dotenv/config'; import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ExpressAdapter } from '@nestjs/platform-express'; import express from 'express'; import cors from 'cors'; @@ -19,8 +34,14 @@ import { } from '@taskly/database'; import type { AppRouter, Context } from '@taskly/trpc'; import { GcsService } from './gcs/gcs.service.js'; +import { BoardBackgroundsService } from './board/board-backgrounds.service.js'; import crypto from 'node:crypto'; +/** + * Extracts and validates a Bearer token from the Authorization header. + * @param header - The Authorization header value + * @returns The JWT token if valid, or null if missing/invalid format + */ function extractBearerToken(header: string | undefined): string | null { if (!header) return null; const [type, token] = header.split(' '); @@ -28,21 +49,52 @@ function extractBearerToken(header: string | undefined): string | null { return token; } +/** + * Sanitizes a filename by removing path separators and limiting length. + * @param name - The filename to sanitize + * @returns Sanitized filename (max 200 chars) + */ function sanitizeFilename(name: string): string { const base = name.replace(/[/\\]/g, '_').trim(); return base.slice(0, 200) || 'file'; } +/** + * Builds a GCS object path for ticket attachments. + * Includes board/ticket hierarchy and random suffix for uniqueness. + * @param boardId - The board ID + * @param ticketId - The ticket ID + * @param filename - The original filename + * @returns GCS object path: boards/{boardId}/tickets/{ticketId}/{randomHex}-{filename} + */ function buildObjectPath(boardId: string, ticketId: string, filename: string): string { const safe = sanitizeFilename(filename); const rand = crypto.randomBytes(8).toString('hex'); return `boards/${boardId}/tickets/${ticketId}/${rand}-${safe}`; } +/** + * Builds a GCS object path for user avatar images. + * Includes user hierarchy and random suffix for uniqueness. + * @param userId - The user ID + * @param filename - The original filename + * @returns GCS object path: users/{userId}/avatar/{randomHex}-{filename} + */ +function buildUserAvatarPath(userId: string, filename: string): string { + const safe = sanitizeFilename(filename); + const rand = crypto.randomBytes(8).toString('hex'); + return `users/${userId}/avatar/${rand}-${safe}`; +} + +/** + * Bootstrap function that initializes the entire API server. + * Sets up Express, NestJS, Swagger, Swagger authentication, and tRPC. + */ async function bootstrap() { + // Initialize Express server instance const server = express(); - // CORS au niveau Express, pour couvrir aussi le middleware tRPC monté sur Express. + // Apply CORS middleware at Express level to cover both REST and tRPC routes server.use( cors({ origin: process.env.CORS_ORIGIN ?? 'http://localhost:3000', @@ -50,18 +102,41 @@ async function bootstrap() { }), ); + // Create and initialize NestJS application with Express adapter const app = await NestFactory.create(AppModule, new ExpressAdapter(server)); - // Optionnel: Nest gère aussi CORS pour ses routes (ex: /health). - // On le laisse, mais l'important est le middleware Express ci-dessus. + // Also enable CORS at NestJS level (for /health and other direct routes) app.enableCors(); + // Configure Swagger/OpenAPI documentation + const swaggerConfig = new DocumentBuilder() + .setTitle('Taskly API') + .setDescription('REST API documentation for Taskly - workspace, board, and ticket management system') + .setVersion('1.0') + // Configure Bearer token authentication for Swagger UI + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + 'bearer', + ) + .build(); + const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + // Setup Swagger UI at /docs endpoint with persistent auth + SwaggerModule.setup('/docs', app, swaggerDocument, { + swaggerOptions: { persistAuthorization: true }, + }); + + // Retrieve Firebase authentication service for token verification const firebaseAuth = app.get(FIREBASE_AUTH) as unknown as { verifyIdToken: (token: string) => Promise<{ uid: string } & Record>; }; + + // Log Firebase configuration in development mode if (process.env.NODE_ENV !== 'production') { try { const firebaseApp = app.get(FIREBASE_ADMIN_APP) as unknown as { options?: { projectId?: string } }; - // Note: firestore.projectId may throw "Client is not yet ready", so we don't touch it here. // eslint-disable-next-line no-console console.log('[firebase-admin] effective projectId', { appProjectId: firebaseApp?.options?.projectId ?? null, @@ -74,23 +149,29 @@ async function bootstrap() { console.warn('[firebase-admin] could not log effective projectId', e); } } + + // Retrieve all database and service providers from NestJS container const usersService = app.get(UsersService); const workspacesService = app.get(WorkspacesService); const boardsService = app.get(BoardsService); + const boardBackgroundsService = app.get(BoardBackgroundsService); const ticketsService = app.get(TicketsService); const ticketRemindersService = app.get(TicketRemindersService); const notificationsService = app.get(NotificationsService); const activityLogsService = app.get(ActivityLogsService); const gcsService = app.get(GcsService); + // Dynamically import tRPC app router const mod = await import('@taskly/trpc'); const appRouter = (mod as { appRouter: AppRouter }).appRouter; - // tRPC endpoint (after Nest is created, so we can reuse its providers) + // Mount tRPC middleware on Express server + // This must be after NestJS is created so we can reuse its providers server.use( '/trpc', createExpressMiddleware({ router: appRouter, + // Error handler for tRPC procedures onError({ error, path, type, req }) { if (process.env.NODE_ENV !== 'production') { const cause = error.cause as @@ -125,16 +206,36 @@ async function bootstrap() { } } }, + // Create tRPC context with authenticated user and database services createContext: async ({ req }): Promise => { + // Extract and validate Bearer token from request headers const token = extractBearerToken(req.headers.authorization); + // User service wrapper with additional GCS operations const users = { getById: usersService.getById.bind(usersService), search: usersService.search.bind(usersService), updateMe: usersService.updateMe.bind(usersService), deleteMe: usersService.deleteMe.bind(usersService), + // Generate signed upload URL for user avatar + createAvatarUpload: async (userId: string, input: { filename: string; contentType: string; resumable?: boolean }) => { + const filename = sanitizeFilename(input.filename); + const contentType = input.contentType.trim() || 'application/octet-stream'; + const objectPath = buildUserAvatarPath(userId, filename); + const upload = await gcsService.signedUploadUrl({ + objectPath, + contentType, + resumable: input.resumable ?? false, + }); + return { objectPath, upload }; + }, + // Generate signed download URL for user avatar + getAvatarDownload: async (objectPath: string) => { + return await gcsService.signedDownloadUrl({ objectPath }); + }, }; + // Workspace service wrapper with member and invitation operations const workspaces = { createWorkspace: workspacesService.createWorkspace.bind(workspacesService), getWorkspaceById: workspacesService.getWorkspaceById.bind(workspacesService), @@ -143,14 +244,14 @@ async function bootstrap() { unarchiveWorkspace: workspacesService.unarchiveWorkspace.bind(workspacesService), listWorkspacesForUser: workspacesService.listWorkspacesForUser.bind(workspacesService), listArchivedWorkspacesForUser: workspacesService.listArchivedWorkspacesForUser.bind(workspacesService), - + // Member management getMember: workspacesService.getMember.bind(workspacesService), upsertMember: workspacesService.upsertMember.bind(workspacesService), removeMember: workspacesService.removeMember.bind(workspacesService), listMembers: workspacesService.listMembers.bind(workspacesService), countMembers: workspacesService.countMembers.bind(workspacesService), countAdmins: workspacesService.countAdmins.bind(workspacesService), - + // Invitation management createInvitation: workspacesService.createInvitation.bind(workspacesService), listPendingInvitations: workspacesService.listPendingInvitations.bind(workspacesService), getInvitation: workspacesService.getInvitation.bind(workspacesService), @@ -159,27 +260,29 @@ async function bootstrap() { declineInvitationByToken: workspacesService.declineInvitationByToken.bind(workspacesService), }; + // Board service wrapper with columns, labels, and tickets operations const boards = { + // Board CRUD getBoardById: boardsService.getBoardById.bind(boardsService), updateBoard: boardsService.updateBoard.bind(boardsService), archiveBoard: boardsService.archiveBoard.bind(boardsService), - + // Board listing and reordering listBoardsForWorkspace: boardsService.listBoardsForWorkspace.bind(boardsService), createBoard: boardsService.createBoard.bind(boardsService), reorderBoards: boardsService.reorderBoards.bind(boardsService), - + // Column management listColumns: boardsService.listColumns.bind(boardsService), createColumn: boardsService.createColumn.bind(boardsService), updateColumn: boardsService.updateColumn.bind(boardsService), deleteColumn: boardsService.deleteColumn.bind(boardsService), reorderColumns: boardsService.reorderColumns.bind(boardsService), - + // Label management listLabels: boardsService.listLabels.bind(boardsService), createLabel: boardsService.createLabel.bind(boardsService), updateLabel: boardsService.updateLabel.bind(boardsService), deleteLabel: boardsService.deleteLabel.bind(boardsService), reorderLabels: boardsService.reorderLabels.bind(boardsService), - + // Ticket querying and movement listTickets: boardsService.listTickets.bind(boardsService), listTicketsByColumn: boardsService.listTicketsByColumn.bind(boardsService), createTicket: boardsService.createTicket.bind(boardsService), @@ -187,38 +290,49 @@ async function bootstrap() { archiveTicket: boardsService.archiveTicket.bind(boardsService), }; + // Board backgrounds service wrapper + const boardBackgrounds = { + list: boardBackgroundsService.list.bind(boardBackgroundsService), + }; + + /** + * Factory function to create ticket service wrapper with role-based authorization. + * @param actorId - The ID of the user performing actions (null for unauthenticated) + * @returns Ticket service wrapper with methods bound to the current actor + */ const makeTickets = (actorId: string | null) => ({ + // Ticket CRUD getById: ticketsService.getById.bind(ticketsService), update: ticketsService.update.bind(ticketsService), archive: ticketsService.archive.bind(ticketsService), - + // Comments listComments: ticketsService.listComments.bind(ticketsService), addComment: ticketsService.addComment.bind(ticketsService), updateComment: ticketsService.updateComment.bind(ticketsService), deleteComment: ticketsService.deleteComment.bind(ticketsService), - + // Assignees getAssigneeIds: ticketsService.getAssigneeIds.bind(ticketsService), addAssignee: ticketsService.addAssignee.bind(ticketsService), removeAssignee: ticketsService.removeAssignee.bind(ticketsService), + // Watch status for notifications getWatchStatus: ticketsService.getWatchStatus.bind(ticketsService), setWatchStatus: ticketsService.setWatchStatus.bind(ticketsService), listWatchUserIds: ticketsService.listWatchUserIds.bind(ticketsService), - + // Labels getLabelIds: ticketsService.getLabelIds.bind(ticketsService), addLabel: ticketsService.addLabel.bind(ticketsService), removeLabel: ticketsService.removeLabel.bind(ticketsService), - + // Checklists and items listChecklists: ticketsService.listChecklists.bind(ticketsService), createChecklist: ticketsService.createChecklist.bind(ticketsService), updateChecklist: ticketsService.updateChecklist.bind(ticketsService), deleteChecklist: ticketsService.deleteChecklist.bind(ticketsService), reorderChecklists: ticketsService.reorderChecklists.bind(ticketsService), - addChecklistItem: ticketsService.addChecklistItem.bind(ticketsService), updateChecklistItem: ticketsService.updateChecklistItem.bind(ticketsService), deleteChecklistItem: ticketsService.deleteChecklistItem.bind(ticketsService), reorderChecklistItems: ticketsService.reorderChecklistItems.bind(ticketsService), - + // Attachments with GCS signed URLs listAttachments: ticketsService.listAttachments.bind(ticketsService), createAttachmentUpload: async ( ticketId: string, @@ -261,6 +375,7 @@ async function bootstrap() { }, }); + // Notifications service wrapper const notifications = { create: notificationsService.create.bind(notificationsService), list: notificationsService.list.bind(notificationsService), @@ -269,6 +384,7 @@ async function bootstrap() { markAllRead: notificationsService.markAllRead.bind(notificationsService), }; + // Ticket reminders service wrapper const ticketReminders = { list: (ticketId: string, input: { userId: string }) => ticketRemindersService.listForUser(ticketId, input.userId), create: (ticketId: string, input: { userId: string; remindAt: string }) => @@ -277,32 +393,67 @@ async function bootstrap() { ticketRemindersService.removeForUser(ticketId, reminderId, input.userId), }; + // Activity logs service wrapper const activityLogs = { create: activityLogsService.create.bind(activityLogsService), listForBoard: activityLogsService.listForBoard.bind(activityLogsService), listForTicket: activityLogsService.listForTicket.bind(activityLogsService), }; + // If no token provided, return unauthenticated context if (!token) { - return { user: null, users, workspaces, boards, tickets: makeTickets(null), ticketReminders, notifications, activityLogs }; + return { + user: null, + users, + workspaces, + boards, + boardBackgrounds, + tickets: makeTickets(null), + ticketReminders, + notifications, + activityLogs, + }; } + // Verify JWT token with Firebase and return authenticated context try { const decoded = await firebaseAuth.verifyIdToken(token); const user = await usersService.ensureUserExists(decoded); - return { user, users, workspaces, boards, tickets: makeTickets(user.id), ticketReminders, notifications, activityLogs }; + return { + user, + users, + workspaces, + boards, + boardBackgrounds, + tickets: makeTickets(user.id), + ticketReminders, + notifications, + activityLogs, + }; } catch (e) { if (process.env.NODE_ENV !== 'production') { const err = e as { message?: string; code?: string }; // eslint-disable-next-line no-console console.warn('[trpc] verifyIdToken failed', { code: err?.code, message: err?.message }); } - return { user: null, users, workspaces, boards, tickets: makeTickets(null), ticketReminders, notifications, activityLogs }; + // Return unauthenticated context on token verification failure + return { + user: null, + users, + workspaces, + boards, + boardBackgrounds, + tickets: makeTickets(null), + ticketReminders, + notifications, + activityLogs, + }; } }, }), ); + // Start server on configured port (default 4000) const port = Number(process.env.PORT ?? 4000); await app.listen(port); // eslint-disable-next-line no-console diff --git a/apps/api/src/ticket/ticket-access.service.ts b/apps/api/src/ticket/ticket-access.service.ts index 67dd889..79dc3bd 100644 --- a/apps/api/src/ticket/ticket-access.service.ts +++ b/apps/api/src/ticket/ticket-access.service.ts @@ -1,8 +1,22 @@ +/** + * Ticket Access Control Service + * + * Provides authorization checks for ticket operations. + * Verifies ticket existence, derives workspace from board, and enforces role-based permissions. + * + * Used by TicketController to ensure users have proper access to ticket resources + * and can perform actions like viewing content, managing comments, or handling attachments. + */ + import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import type { TicketModel, WorkspaceRole } from '@taskly/database'; import { BoardsService, TicketsService, WorkspacesService } from '@taskly/database'; import { hasPermission, ticketRolePermissions } from '@taskly/shared'; +/** + * Service for ticket-level access control. + * Resolves ticket -> board -> workspace hierarchy to determine user permissions. + */ @Injectable() export class TicketAccessService { constructor( @@ -11,12 +25,34 @@ export class TicketAccessService { private readonly workspaces: WorkspacesService, ) {} + /** + * Retrieves a ticket by ID or throws an error if not found. + * @param ticketId - The ticket ID + * @returns The ticket model + * @throws NotFoundException if ticket doesn't exist + */ async getTicketOrThrow(ticketId: string): Promise { const t = await this.tickets.getById(ticketId); if (!t) throw new NotFoundException('Ticket not found'); return t; } + /** + * Determines a user's workspace role by tracing through ticket -> board -> workspace hierarchy. + * Verifies that ticket's board exists and user is a workspace member. + * + * @param userId - The user ID + * @param ticket - The ticket model + * @returns Object containing user's role and workspace ID + * @throws NotFoundException if board doesn't exist or is archived + * @throws ForbiddenException if user is not a workspace member + * + * @example + * const { role, workspaceId } = await ticketAccessService.getWorkspaceRoleForTicketOrThrow( + * userId, + * ticket + * ); + */ async getWorkspaceRoleForTicketOrThrow( userId: string, ticket: TicketModel, @@ -29,11 +65,27 @@ export class TicketAccessService { return { role: member.role, workspaceId }; } + /** + * Verifies that a role has a required permission. + * Throws if permission check fails. + * + * @param role - The user's workspace role + * @param permission - The required permission string (e.g., 'ticket.content.read') + * @throws ForbiddenException if permission is not granted + */ require(role: WorkspaceRole, permission: string): void { const grants = ticketRolePermissions[role] ?? []; if (!hasPermission(grants, permission)) throw new ForbiddenException('Missing permission'); } + /** + * Checks if a role has a required permission without throwing. + * Useful for conditional logic (e.g., field filtering). + * + * @param role - The user's workspace role + * @param permission - The required permission string + * @returns true if permission is granted, false otherwise + */ has(role: WorkspaceRole, permission: string): boolean { const grants = ticketRolePermissions[role] ?? []; return hasPermission(grants, permission); diff --git a/apps/api/src/ticket/ticket-reminders-dispatcher.service.ts b/apps/api/src/ticket/ticket-reminders-dispatcher.service.ts index f2edaa9..026ab14 100644 --- a/apps/api/src/ticket/ticket-reminders-dispatcher.service.ts +++ b/apps/api/src/ticket/ticket-reminders-dispatcher.service.ts @@ -1,9 +1,33 @@ +/** + * Ticket Reminders Dispatcher Service + * + * Manages the scheduled dispatch of ticket reminders. + * + * Features: + * - Polls database for due reminders at regular intervals + * - Sends notifications to ticket watchers and assignees + * - Supports both polling and HTTP-triggered dispatch (Cloud Scheduler) + * - Handles graceful startup/shutdown with NestJS lifecycle hooks + * - Fault-tolerant: individual reminder failures don't crash the service + * + * Environment variables: + * - ENABLE_TICKET_REMINDERS_POLLING: Enable automatic polling (default true, except production) + * - DISABLE_TICKET_REMINDERS_DISPATCHER: Disable completely + * - TICKET_REMINDERS_POLL_INTERVAL_MS: Polling interval (default 60000ms = 1min) + * - CRON_SECRET: Secret for HTTP dispatch endpoint authentication + */ + import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { NotificationsService, TicketRemindersService, TicketsService } from '@taskly/database'; import crypto from 'node:crypto'; +/** + * Service for dispatching ticket reminders. + * Implements NestJS lifecycle hooks for polling management. + */ @Injectable() export class TicketRemindersDispatcherService implements OnModuleInit, OnModuleDestroy { + /** Interval timer for polling reminders (null if not running) */ private timer: NodeJS.Timeout | null = null; constructor( @@ -12,6 +36,13 @@ export class TicketRemindersDispatcherService implements OnModuleInit, OnModuleD private readonly notifications: NotificationsService, ) {} + /** + * Type guard to validate reminder object structure. + * Ensures reminder has all required fields from database claim operation. + * + * @param x - Object to validate + * @returns true if object is a valid reminder with required fields + */ private static isDueReminder(x: unknown): x is { id: string; boardId: string; @@ -32,12 +63,33 @@ export class TicketRemindersDispatcherService implements OnModuleInit, OnModuleD ); } + /** + * Processes due reminders once and sends notifications. + * + * This is the core dispatch logic that: + * 1. Claims due reminders from the database + * 2. Fetches ticket details + * 3. Creates notifications for watchers and assignees + * 4. Marks reminders as processed + * + * Designed for fault-tolerance: individual reminder failures don't stop processing. + * + * @param nowMs - Current timestamp in milliseconds (default: Date.now()) + * @returns Statistics: claimed reminders and successfully sent notifications + * + * @example + * const { claimed, sent } = await dispatcher.runOnce(); + * console.log(`Claimed ${claimed} reminders, sent ${sent} notifications`); + */ async runOnce(nowMs = Date.now()): Promise<{ claimed: number; sent: number }> { // In dev, if @taskly/database runtime build is stale, Nest may inject `undefined` here. // Never crash the API because of the dispatcher. if (!this.reminders) return { claimed: 0, sent: 0 }; + // Generate unique claim ID to prevent duplicate processing across instances const claimId = crypto.randomBytes(12).toString('hex'); + + // Fetch due reminders with distributed lock (claim/release pattern) const due = typeof (this.reminders as unknown as { claimDue?: unknown }).claimDue === 'function' ? await this.reminders.claimDue(nowMs, { limit: 200, claimId, claimTtlMs: 5 * 60_000 }) @@ -49,13 +101,19 @@ export class TicketRemindersDispatcherService implements OnModuleInit, OnModuleD ).filter(TicketRemindersDispatcherService.isDueReminder); let sent = 0; + + // Process each due reminder independently (one failure doesn't stop others) for (const r of due) { try { + // Get ticket details to include in notification const t = await this.tickets.getById(r.ticketId); if (!t) continue; + // Notify both the reminder creator and all ticket assignees const targets = Array.from(new Set([r.userId, ...t.assigneeIds].filter((x) => typeof x === 'string' && x.length > 0))); const notificationIds: Record = {}; + + // Send notification to each target (best-effort per recipient) for (const userId of targets) { try { const notif = await this.notifications.create(userId, { @@ -67,23 +125,24 @@ export class TicketRemindersDispatcherService implements OnModuleInit, OnModuleD }); notificationIds[userId] = notif.id; } catch { - // Best-effort per recipient. + // Best-effort per recipient - continue with other users } } - // Mark as processed so we don't retry forever. + // Mark reminder as processed so we don't retry forever if (typeof (this.reminders as unknown as { markSent?: unknown }).markSent === 'function') { await this.reminders.markSent(r.boardId, r.ticketId, r.id, { notificationIds, claimId }); } sent++; } catch { // Best-effort: a reminder dispatch should never crash the API process. + // Release the claim so another instance can retry try { if (typeof (this.reminders as unknown as { releaseClaim?: unknown }).releaseClaim === 'function') { await this.reminders.releaseClaim(r.boardId, r.ticketId, r.id, claimId); } } catch { - // ignore + // ignore - already in error state } } } @@ -91,6 +150,17 @@ export class TicketRemindersDispatcherService implements OnModuleInit, OnModuleD return { claimed: due.length, sent }; } + /** + * NestJS lifecycle hook: initializes polling timer on module startup. + * + * Behavior: + * - Test environment: polling disabled + * - DISABLE_TICKET_REMINDERS_DISPATCHER=1: completely disabled + * - Production: polling disabled by default (use Cloud Scheduler instead) + * - Dev/local: polling enabled by default + * + * Runs dispatch once on startup, then at configurable interval. + */ onModuleInit(): void { if (process.env.NODE_ENV === 'test') return; if (process.env.DISABLE_TICKET_REMINDERS_DISPATCHER === '1') return; @@ -100,10 +170,11 @@ export class TicketRemindersDispatcherService implements OnModuleInit, OnModuleD const enablePolling = process.env.ENABLE_TICKET_REMINDERS_POLLING === '1' || process.env.NODE_ENV !== 'production'; if (!enablePolling) return; + // Get polling interval from environment, default to 60 seconds const intervalMsRaw = Number(process.env.TICKET_REMINDERS_POLL_INTERVAL_MS ?? 60_000); const intervalMs = Number.isFinite(intervalMsRaw) && intervalMsRaw > 0 ? intervalMsRaw : 60_000; - // Run once on startup, then periodically. + // Run once on startup (best-effort), then periodically this.runOnce().catch(() => { // ignore (best-effort) }); @@ -114,6 +185,10 @@ export class TicketRemindersDispatcherService implements OnModuleInit, OnModuleD }, intervalMs); } + /** + * NestJS lifecycle hook: cleans up polling timer on module destruction. + * Called when application is shutting down. + */ onModuleDestroy(): void { if (this.timer) clearInterval(this.timer); this.timer = null; diff --git a/apps/api/src/ticket/ticket-reminders.controller.ts b/apps/api/src/ticket/ticket-reminders.controller.ts index 1739856..764ce67 100644 --- a/apps/api/src/ticket/ticket-reminders.controller.ts +++ b/apps/api/src/ticket/ticket-reminders.controller.ts @@ -1,11 +1,74 @@ +/** + * Internal Ticket Reminders Controller + * + * Provides an HTTP endpoint for triggering reminder dispatch. + * + * This is an internal-only endpoint meant to be called by: + * - Cloud Scheduler (production): Sends authenticated requests with secret header + * - Development: Can be called without auth for testing + * + * The endpoint triggers the TicketRemindersDispatcherService to process due reminders + * and send notifications to users. + * + * Not exposed in the public Swagger documentation (tagged as 'internal'). + */ + import { Controller, ForbiddenException, Post, Req } from '@nestjs/common'; +import { ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; import { TicketRemindersDispatcherService } from './ticket-reminders-dispatcher.service.js'; +/** + * Controller for internal ticket reminder operations. + * Handles scheduled dispatch of ticket reminders via HTTP. + */ +@ApiTags('internal') @Controller('/api/internal/ticket-reminders') export class TicketRemindersController { constructor(private readonly dispatcher: TicketRemindersDispatcherService) {} + /** + * HTTP endpoint to manually dispatch ticket reminders. + * + * Triggers immediate processing of due reminders and sends notifications. + * Intended to be called by Cloud Scheduler in production. + * + * Security: + * - Production: Requires valid x-taskly-cron-secret header (must match CRON_SECRET env var) + * - Development: Optional secret (if CRON_SECRET is set, header must match) + * + * @param req - Express request object (used to extract cron secret header) + * @returns Status object with dispatch statistics + * @throws ForbiddenException if secret validation fails + * + * @example + * // Production call via Cloud Scheduler + * curl -X POST http://api.example.com/api/internal/ticket-reminders/dispatch \ + * -H "x-taskly-cron-secret: $CRON_SECRET" + * + * @example + * // Response + * { + * "ok": true, + * "claimed": 12, // Number of reminders retrieved + * "sent": 10 // Number of notifications successfully sent + * } + */ + @ApiHeader({ + name: 'x-taskly-cron-secret', + required: false, + description: 'Secret requis en production pour déclencher la tâche', + }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { + ok: { type: 'boolean', example: true }, + claimed: { type: 'number', example: 12 }, + sent: { type: 'number', example: 10 }, + }, + }, + }) @Post('/dispatch') async dispatch(@Req() req: Request) { const expected = process.env.CRON_SECRET ?? ''; @@ -20,6 +83,7 @@ export class TicketRemindersController { if (expected && provided !== expected) throw new ForbiddenException('Invalid cron secret'); } + // Execute reminder dispatch const res = await this.dispatcher.runOnce(Date.now()); return { ok: true, ...res }; } diff --git a/apps/api/src/ticket/ticket.controller.ts b/apps/api/src/ticket/ticket.controller.ts index 1cb45af..8bd2a81 100644 --- a/apps/api/src/ticket/ticket.controller.ts +++ b/apps/api/src/ticket/ticket.controller.ts @@ -10,6 +10,7 @@ import { Post, UseGuards, } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOkResponse, ApiProperty, ApiTags } from '@nestjs/swagger'; import { CurrentUser, FirebaseAuthGuard } from '@taskly/auth'; import type { TicketUpdateInput, UserModel } from '@taskly/database'; import { TicketsService, UsersService } from '@taskly/database'; @@ -17,26 +18,309 @@ import { TicketAccessService } from './ticket-access.service.js'; import { GcsService } from '../gcs/gcs.service.js'; import crypto from 'node:crypto'; -type PatchTicketDto = { +class SignedUrlDto { + @ApiProperty({ example: 'https://storage.googleapis.com/...' }) + url!: string; + + @ApiProperty({ enum: ['PUT', 'GET', 'POST'] }) + method!: 'PUT' | 'GET' | 'POST'; + + @ApiProperty({ type: 'object', example: { 'Content-Type': 'application/pdf' } }) + headers!: Record; + + @ApiProperty({ example: '2024-01-01T10:15:00.000Z' }) + expiresAt!: string; + + @ApiProperty({ enum: ['write', 'resumable', 'read'] }) + type!: 'write' | 'resumable' | 'read'; +} + +class TicketDto { + @ApiProperty({ example: 'ticket_123' }) + id!: string; + + @ApiProperty({ example: 'board_123' }) + boardId!: string; + + @ApiProperty({ example: 'col_123' }) + columnId!: string; + + @ApiProperty({ example: 'Fix login' }) + title!: string; + + @ApiProperty({ example: 'Details' }) + description!: string; + + @ApiProperty({ example: '2024-01-10T10:00:00.000Z', nullable: true }) + dueDate!: string | null; + + @ApiProperty({ type: [String], required: false, example: ['usr_1', 'usr_2'] }) + assigneeIds?: string[]; + + @ApiProperty({ type: [String], example: ['label_1'] }) + labelIds!: string[]; + + @ApiProperty({ example: 0 }) + position!: number; + + @ApiProperty({ example: false }) + isArchived!: boolean; + + @ApiProperty({ example: null, nullable: true }) + archivedAt!: string | null; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class TicketDetailsDto { + @ApiProperty({ example: 'ticket_123' }) + id!: string; + + @ApiProperty({ example: 'board_123' }) + boardId!: string; + + @ApiProperty({ example: 'col_123' }) + columnId!: string; + + @ApiProperty({ example: 'Fix login' }) + title!: string; + + @ApiProperty({ example: 'Details' }) + description!: string; + + @ApiProperty({ example: '2024-01-10T10:00:00.000Z', nullable: true }) + dueDate!: string | null; + + @ApiProperty({ type: [String], required: false, example: ['usr_1', 'usr_2'] }) + assigneeIds?: string[]; + + @ApiProperty({ type: [String], example: ['label_1'] }) + labelIds!: string[]; + + @ApiProperty({ example: 0 }) + position!: number; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class TicketCommentDto { + @ApiProperty({ example: 'cmt_123' }) + id!: string; + + @ApiProperty({ example: 'ticket_123' }) + ticketId!: string; + + @ApiProperty({ example: 'usr_123' }) + authorId!: string; + + @ApiProperty({ example: 'Looks good' }) + content!: string; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class AssigneeIdsDto { + @ApiProperty({ type: [String], example: ['usr_1', 'usr_2'] }) + assigneeIds!: string[]; +} + +class LabelIdsDto { + @ApiProperty({ type: [String], example: ['label_1', 'label_2'] }) + labelIds!: string[]; +} + +class TicketChecklistItemDto { + @ApiProperty({ example: 'item_123' }) + id!: string; + + @ApiProperty({ example: 'ticket_123' }) + ticketId!: string; + + @ApiProperty({ example: 'chk_123' }) + checklistId!: string; + + @ApiProperty({ example: 'Do the thing' }) + content!: string; + + @ApiProperty({ example: false }) + isDone!: boolean; + + @ApiProperty({ example: 0 }) + position!: number; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class TicketChecklistDto { + @ApiProperty({ example: 'chk_123' }) + id!: string; + + @ApiProperty({ example: 'ticket_123' }) + ticketId!: string; + + @ApiProperty({ example: 'Release' }) + title!: string; + + @ApiProperty({ example: 0 }) + position!: number; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; + + @ApiProperty({ type: [TicketChecklistItemDto] }) + items!: TicketChecklistItemDto[]; +} + +class TicketAttachmentDto { + @ApiProperty({ example: 'att_123' }) + id!: string; + + @ApiProperty({ example: 'ticket_123' }) + ticketId!: string; + + @ApiProperty({ example: 'usr_123' }) + createdBy!: string; + + @ApiProperty({ example: 'spec.pdf' }) + filename!: string; + + @ApiProperty({ example: 'application/pdf' }) + contentType!: string; + + @ApiProperty({ example: 'boards/...' }) + objectPath!: string; + + @ApiProperty({ enum: ['pending', 'uploaded'] }) + status!: 'pending' | 'uploaded'; + + @ApiProperty({ example: 12345, nullable: true }) + size!: number | null; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class AttachmentUploadResponseDto { + @ApiProperty({ type: TicketAttachmentDto }) + attachment!: TicketAttachmentDto; + + @ApiProperty({ type: SignedUrlDto }) + upload!: SignedUrlDto; +} + +class AttachmentDownloadResponseDto { + @ApiProperty({ type: TicketAttachmentDto }) + attachment!: TicketAttachmentDto; + + @ApiProperty({ type: SignedUrlDto }) + download!: SignedUrlDto; +} + +class PatchTicketDto { + @ApiProperty({ required: false, example: 'Fix login' }) title?: string; + + @ApiProperty({ required: false, example: 'Details' }) description?: string; + + @ApiProperty({ required: false, example: '2024-01-10T10:00:00.000Z', nullable: true }) dueDate?: string | null; -}; - -type CreateCommentDto = { content?: string }; -type PatchCommentDto = { content?: string }; -type AddAssigneeDto = { userId?: string }; -type CreateChecklistDto = { title?: string }; -type PatchChecklistDto = { title?: string }; -type ReorderChecklistsDto = { checklistIds?: string[] }; -type CreateChecklistItemDto = { content?: string }; -type PatchChecklistItemDto = { content?: string; isDone?: boolean }; -type ReorderChecklistItemsDto = { itemIds?: string[] }; -type AddLabelDto = { labelId?: string }; -type CreateAttachmentUploadUrlDto = { filename?: string; contentType?: string; resumable?: boolean }; -type CompleteAttachmentDto = { size?: number }; +} + +class CreateCommentDto { + @ApiProperty({ required: false, example: 'Looks good' }) + content?: string; +} + +class PatchCommentDto { + @ApiProperty({ required: false, example: 'Looks great' }) + content?: string; +} + +class AddAssigneeDto { + @ApiProperty({ required: false, example: 'usr_123' }) + userId?: string; +} + +class CreateChecklistDto { + @ApiProperty({ required: false, example: 'Release' }) + title?: string; +} + +class PatchChecklistDto { + @ApiProperty({ required: false, example: 'Release' }) + title?: string; +} + +class ReorderChecklistsDto { + @ApiProperty({ required: false, type: [String], example: ['chk_1', 'chk_2'] }) + checklistIds?: string[]; +} + +class CreateChecklistItemDto { + @ApiProperty({ required: false, example: 'Do the thing' }) + content?: string; +} + +class PatchChecklistItemDto { + @ApiProperty({ required: false, example: 'Do the thing' }) + content?: string; + + @ApiProperty({ required: false, example: true }) + isDone?: boolean; +} + +class ReorderChecklistItemsDto { + @ApiProperty({ required: false, type: [String], example: ['item_1', 'item_2'] }) + itemIds?: string[]; +} + +class AddLabelDto { + @ApiProperty({ required: false, example: 'label_123' }) + labelId?: string; +} + +class CreateAttachmentUploadUrlDto { + @ApiProperty({ required: false, example: 'spec.pdf' }) + filename?: string; + + @ApiProperty({ required: false, example: 'application/pdf' }) + contentType?: string; + + @ApiProperty({ required: false, example: true }) + resumable?: boolean; +} + +class CompleteAttachmentDto { + @ApiProperty({ required: false, example: 12345 }) + size?: number; +} @UseGuards(FirebaseAuthGuard) +@ApiTags('tickets') +@ApiBearerAuth('bearer') @Controller('/api/tickets') export class TicketController { constructor( @@ -58,6 +342,7 @@ export class TicketController { } @Get('/:ticketId') + @ApiOkResponse({ type: TicketDetailsDto }) async getTicket(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -82,6 +367,8 @@ export class TicketController { } @Patch('/:ticketId') + @ApiBody({ type: PatchTicketDto }) + @ApiOkResponse({ type: TicketDto }) async patchTicket( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -114,6 +401,12 @@ export class TicketController { } @Delete('/:ticketId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async deleteTicket(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -130,6 +423,7 @@ export class TicketController { // Comments @Get('/:ticketId/comments') + @ApiOkResponse({ type: [TicketCommentDto] }) async listComments(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -144,6 +438,8 @@ export class TicketController { } @Post('/:ticketId/comments') + @ApiBody({ type: CreateCommentDto }) + @ApiOkResponse({ type: TicketCommentDto }) async addComment( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -166,6 +462,8 @@ export class TicketController { } @Patch('/:ticketId/comments/:commentId') + @ApiBody({ type: PatchCommentDto }) + @ApiOkResponse({ type: TicketCommentDto }) async patchComment( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -189,6 +487,12 @@ export class TicketController { } @Delete('/:ticketId/comments/:commentId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async deleteComment( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -209,6 +513,7 @@ export class TicketController { // Assignees @Get('/:ticketId/assignees') + @ApiOkResponse({ type: AssigneeIdsDto }) async listAssignees(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -224,6 +529,13 @@ export class TicketController { } @Post('/:ticketId/assignees') + @ApiBody({ type: AddAssigneeDto }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async addAssignee( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -249,6 +561,12 @@ export class TicketController { } @Delete('/:ticketId/assignees/:userId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async removeAssignee( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -270,6 +588,7 @@ export class TicketController { // Labels (assigned on ticket) @Get('/:ticketId/labels') + @ApiOkResponse({ type: LabelIdsDto }) async listLabels(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -285,6 +604,13 @@ export class TicketController { } @Post('/:ticketId/labels') + @ApiBody({ type: AddLabelDto }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async addLabel(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @Body() body: AddLabelDto) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -304,6 +630,12 @@ export class TicketController { } @Delete('/:ticketId/labels/:labelId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async removeLabel(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @Param('labelId') labelId: string) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -321,6 +653,7 @@ export class TicketController { // Checklists @Get('/:ticketId/checklists') + @ApiOkResponse({ type: [TicketChecklistDto] }) async listChecklists(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -335,6 +668,8 @@ export class TicketController { } @Post('/:ticketId/checklists') + @ApiBody({ type: CreateChecklistDto }) + @ApiOkResponse({ type: TicketChecklistDto }) async createChecklist(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @Body() body: CreateChecklistDto) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -353,6 +688,13 @@ export class TicketController { } @Patch('/:ticketId/checklists/order') + @ApiBody({ type: ReorderChecklistsDto }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async reorderChecklists( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -377,6 +719,8 @@ export class TicketController { } @Patch('/:ticketId/checklists/:checklistId') + @ApiBody({ type: PatchChecklistDto }) + @ApiOkResponse({ type: TicketChecklistDto }) async patchChecklist( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -400,6 +744,12 @@ export class TicketController { } @Delete('/:ticketId/checklists/:checklistId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async deleteChecklist( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -420,6 +770,8 @@ export class TicketController { // Checklist items @Post('/:ticketId/checklists/:checklistId/items') + @ApiBody({ type: CreateChecklistItemDto }) + @ApiOkResponse({ type: TicketChecklistItemDto }) async addChecklistItem( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -443,6 +795,13 @@ export class TicketController { } @Patch('/:ticketId/checklists/:checklistId/items/order') + @ApiBody({ type: ReorderChecklistItemsDto }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async reorderChecklistItems( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -468,6 +827,8 @@ export class TicketController { } @Patch('/:ticketId/checklists/:checklistId/items/:itemId') + @ApiBody({ type: PatchChecklistItemDto }) + @ApiOkResponse({ type: TicketChecklistItemDto }) async patchChecklistItem( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -498,6 +859,12 @@ export class TicketController { } @Delete('/:ticketId/checklists/:checklistId/items/:itemId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async deleteChecklistItem( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -520,6 +887,7 @@ export class TicketController { // Attachments (GCS signed URLs + metadata) @Get('/:ticketId/attachments') + @ApiOkResponse({ type: [TicketAttachmentDto] }) async listAttachments(@CurrentUser() user: UserModel, @Param('ticketId') ticketId: string) { const ticket = await this.access.getTicketOrThrow(ticketId); const { role } = await this.access.getWorkspaceRoleForTicketOrThrow(user.id, ticket); @@ -535,6 +903,8 @@ export class TicketController { } @Post('/:ticketId/attachments/upload-url') + @ApiBody({ type: CreateAttachmentUploadUrlDto }) + @ApiOkResponse({ type: AttachmentUploadResponseDto }) async createAttachmentUploadUrl( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -577,6 +947,8 @@ export class TicketController { } @Post('/:ticketId/attachments/:attachmentId/complete') + @ApiBody({ type: CompleteAttachmentDto }) + @ApiOkResponse({ type: TicketAttachmentDto }) async completeAttachment( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -602,6 +974,7 @@ export class TicketController { } @Get('/:ticketId/attachments/:attachmentId/download-url') + @ApiOkResponse({ type: AttachmentDownloadResponseDto }) async getAttachmentDownloadUrl( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, @@ -620,6 +993,12 @@ export class TicketController { } @Delete('/:ticketId/attachments/:attachmentId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async deleteAttachment( @CurrentUser() user: UserModel, @Param('ticketId') ticketId: string, diff --git a/apps/api/src/ticket/ticket.module.ts b/apps/api/src/ticket/ticket.module.ts index 09fe01e..0fc787d 100644 --- a/apps/api/src/ticket/ticket.module.ts +++ b/apps/api/src/ticket/ticket.module.ts @@ -1,3 +1,31 @@ +/** + * Ticket Module + * + * Provides REST endpoints for ticket management and reminders. + * + * Controllers: + * - TicketController: Public ticket operations (CRUD, comments, assignments, attachments) + * - TicketRemindersController: Internal endpoint for scheduled reminder dispatch + * + * Features: + * - Ticket CRUD (create, read, update, archive) + * - Comments and discussions + * - Assignee management + * - Label assignment + * - Checklists with items + * - File attachments with GCS signed URLs + * - Watch status for notifications + * - Ticket reminders with scheduled dispatch + * + * All public endpoints require Firebase authentication. + * Access is controlled by workspace membership and ticket-level permissions. + * + * Related Services: + * - TicketAccessService: Authorization and permission checks + * - GcsService: Google Cloud Storage for file uploads/downloads + * - TicketRemindersDispatcherService: Scheduled reminder dispatch + */ + import { Module } from '@nestjs/common'; import { TicketController } from './ticket.controller.js'; import { TicketRemindersController } from './ticket-reminders.controller.js'; diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 27b50ee..4d2b8da 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -9,23 +9,70 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOkResponse, ApiProperty, ApiTags } from '@nestjs/swagger'; import { FirebaseAuthGuard, CurrentUser } from '@taskly/auth'; import type { UserModel, UserUpdateInput } from '@taskly/database'; import { UsersService } from '@taskly/database'; -type UpdateMeDto = UserUpdateInput; +class UserDto { + @ApiProperty({ example: 'usr_123' }) + id!: string; + @ApiProperty({ example: 'jdoe' }) + username!: string; + + @ApiProperty({ example: 'John' }) + first_name!: string; + + @ApiProperty({ example: 'Doe' }) + last_name!: string; + + @ApiProperty({ example: 'Product designer', nullable: true }) + description!: string; + + @ApiProperty({ + type: 'object', + nullable: true, + description: 'Avatar payload (initials or image).', + }) + avatar!: Record | null; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class UpdateMeDto implements UserUpdateInput { + @ApiProperty({ required: false, example: 'jdoe' }) + username?: string; + + @ApiProperty({ required: false, example: 'John' }) + first_name?: string; + + @ApiProperty({ required: false, example: 'Doe' }) + last_name?: string; + + @ApiProperty({ required: false, example: 'Product designer' }) + description?: string; +} + +@ApiTags('users') +@ApiBearerAuth('bearer') @Controller('/users') export class UserController { constructor(private readonly users: UsersService) {} @UseGuards(FirebaseAuthGuard) + @ApiOkResponse({ type: UserDto }) @Get('/me') me(@CurrentUser() user: UserModel) { return user; } @UseGuards(FirebaseAuthGuard) + @ApiOkResponse({ type: [UserDto] }) @Get('/search') async search( @Query('q') q = '', @@ -38,6 +85,7 @@ export class UserController { } @UseGuards(FirebaseAuthGuard) + @ApiOkResponse({ type: UserDto }) @Get('/:id') async getById(@Param('id') id: string) { const user = await this.users.getById(id); @@ -46,6 +94,8 @@ export class UserController { } @UseGuards(FirebaseAuthGuard) + @ApiBody({ type: UpdateMeDto }) + @ApiOkResponse({ type: UserDto }) @Patch('/me') async updateMe( @CurrentUser() user: UserModel, @@ -61,6 +111,12 @@ export class UserController { } @UseGuards(FirebaseAuthGuard) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) @Delete('/me') async deleteMe(@CurrentUser() user: UserModel) { await this.users.deleteMe(user.id); diff --git a/apps/api/src/user/user.module.ts b/apps/api/src/user/user.module.ts index b9cb65b..fa3ade7 100644 --- a/apps/api/src/user/user.module.ts +++ b/apps/api/src/user/user.module.ts @@ -1,3 +1,18 @@ +/** + * User Module + * + * Provides REST endpoints for user profile management. + * + * Features: + * - Profile retrieval (/users/me) + * - User search with limit + * - Profile updates (username, name, description) + * - Account deletion + * - User lookup by ID + * + * All endpoints require Firebase authentication (Bearer JWT). + */ + import { Module } from '@nestjs/common'; import { UserController } from './user.controller.js'; diff --git a/apps/api/src/workspace/permissions.ts b/apps/api/src/workspace/permissions.ts index 91f2921..336fdd1 100644 --- a/apps/api/src/workspace/permissions.ts +++ b/apps/api/src/workspace/permissions.ts @@ -1,3 +1,13 @@ +/** + * Workspace Permission Exports + * + * Re-exports permission types and utilities from the shared package. + * This maintains a clean separation between API layer and shared logic. + * + * The actual permission definitions and role mappings are managed + * in the @taskly/shared package for consistency across the application. + */ + export { type Permission, workspaceRolePermissions, diff --git a/apps/api/src/workspace/workspace-access.service.ts b/apps/api/src/workspace/workspace-access.service.ts index d093f42..1b4f029 100644 --- a/apps/api/src/workspace/workspace-access.service.ts +++ b/apps/api/src/workspace/workspace-access.service.ts @@ -1,24 +1,68 @@ +/** + * Workspace Access Control Service + * + * Provides authorization checks for workspace operations. + * Verifies workspace membership, validates user roles, and enforces permissions. + * + * Used by WorkspaceController to ensure only authorized users can access resources. + */ + import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import type { WorkspaceMemberModel, WorkspaceModel, WorkspaceRole } from '@taskly/database'; import { WorkspacesService } from '@taskly/database'; import { hasPermission, workspaceRolePermissions } from './permissions.js'; +/** + * Service for workspace-level access control. + * Handles permission checking and membership verification. + */ @Injectable() export class WorkspaceAccessService { constructor(private readonly workspaces: WorkspacesService) {} + /** + * Retrieves a workspace by ID or throws an error if not found or archived. + * @param workspaceId - The workspace ID + * @returns The workspace model + * @throws NotFoundException if workspace doesn't exist or is archived + */ async getWorkspaceOrThrow(workspaceId: string): Promise { const ws = await this.workspaces.getWorkspaceById(workspaceId); if (!ws || ws.isArchived) throw new NotFoundException('Workspace not found'); return ws; } + /** + * Retrieves a workspace member by ID or throws an error if not found. + * @param workspaceId - The workspace ID + * @param userId - The user ID + * @returns The workspace member model + * @throws ForbiddenException if user is not a member of the workspace + */ async getMemberOrThrow(workspaceId: string, userId: string): Promise { const m = await this.workspaces.getMember(workspaceId, userId); if (!m) throw new ForbiddenException('Not a workspace member'); return m; } + /** + * Verifies that a user has a required permission in a workspace. + * Combines workspace and member retrieval with permission checking. + * + * @param workspaceId - The workspace ID + * @param userId - The user ID to check permissions for + * @param required - The required permission string (e.g., 'workspace.boards.read') + * @returns Object containing workspace, member, and their role + * @throws NotFoundException if workspace not found or archived + * @throws ForbiddenException if user is not a member or lacks permission + * + * @example + * const { role } = await accessService.requirePermission( + * 'workspace-123', + * 'user-456', + * 'workspace.members.write' + * ); + */ async requirePermission( workspaceId: string, userId: string, @@ -34,6 +78,14 @@ export class WorkspaceAccessService { return { workspace, member, role }; } + /** + * Validates that an ID is not empty or whitespace-only. + * Useful for defensive parameter validation. + * + * @param id - The ID to validate + * @param label - Human-readable field name for error messages (default 'id') + * @throws BadRequestException if ID is empty or whitespace + */ assertNonEmptyId(id: string, label = 'id') { if (!id || !id.trim()) throw new BadRequestException(`Missing ${label}`); } diff --git a/apps/api/src/workspace/workspace.controller.ts b/apps/api/src/workspace/workspace.controller.ts index 21f6a23..5df93b7 100644 --- a/apps/api/src/workspace/workspace.controller.ts +++ b/apps/api/src/workspace/workspace.controller.ts @@ -12,22 +12,278 @@ import { Post, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOkResponse, + ApiProperty, + ApiTags, +} from '@nestjs/swagger'; import { CurrentUser, FirebaseAuthGuard } from '@taskly/auth'; -import type { UserModel, WorkspaceRole } from '@taskly/database'; +import type { BoardBackground, UserModel, WorkspaceRole } from '@taskly/database'; import { BoardsService, UsersService, WorkspacesService } from '@taskly/database'; import { canAssignRole } from './permissions.js'; import { WorkspaceAccessService } from './workspace-access.service.js'; -type CreateWorkspaceDto = { title?: string; description?: string }; -type PatchWorkspaceDto = { title?: string; description?: string }; +class WorkspaceListItemDto { + @ApiProperty({ example: 'ws_123' }) + id!: string; + + @ApiProperty({ example: 'Acme Workspace' }) + title!: string; + + @ApiProperty({ example: 'Main team workspace' }) + description!: string; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class WorkspaceStatsDto { + @ApiProperty({ example: 5 }) + membersCount!: number; + + @ApiProperty({ example: 12 }) + boardsCount!: number; +} + +class WorkspaceDetailsDto { + @ApiProperty({ example: 'ws_123' }) + id!: string; + + @ApiProperty({ example: 'Acme Workspace' }) + title!: string; + + @ApiProperty({ example: 'Main team workspace' }) + description!: string; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; + + @ApiProperty({ type: WorkspaceStatsDto }) + stats!: WorkspaceStatsDto; +} + +class WorkspaceDto { + @ApiProperty({ example: 'ws_123' }) + id!: string; + + @ApiProperty({ example: 'Acme Workspace' }) + title!: string; + + @ApiProperty({ example: 'Main team workspace' }) + description!: string; + + @ApiProperty({ example: false }) + isArchived!: boolean; + + @ApiProperty({ example: null, nullable: true }) + archivedAt!: string | null; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class WorkspaceMemberDto { + @ApiProperty({ example: 'usr_123' }) + userId!: string; + + @ApiProperty({ enum: ['admin', 'maintainer', 'editor', 'viewer'] }) + role!: WorkspaceRole; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class WorkspaceInvitationDto { + @ApiProperty({ example: 'inv_123' }) + id!: string; + + @ApiProperty({ example: 'ws_123' }) + workspaceId!: string; + + @ApiProperty({ example: 'token_abc' }) + token!: string; + + @ApiProperty({ enum: ['admin', 'maintainer', 'editor', 'viewer'] }) + role!: WorkspaceRole; + + @ApiProperty({ example: 'usr_123' }) + createdBy!: string; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-08T10:00:00.000Z' }) + expiresAt!: string; + + @ApiProperty({ example: null, nullable: true }) + acceptedAt!: string | null; + + @ApiProperty({ example: null, nullable: true }) + acceptedBy!: string | null; + + @ApiProperty({ example: null, nullable: true }) + declinedAt!: string | null; + + @ApiProperty({ example: null, nullable: true }) + declinedBy!: string | null; -type AddMemberDto = { userId?: string; role?: WorkspaceRole }; -type PatchMemberDto = { role?: WorkspaceRole }; + @ApiProperty({ example: null, nullable: true }) + cancelledAt!: string | null; -type CreateBoardDto = { title?: string; backgroundColor?: string | null }; -type ReorderBoardsDto = { boardIds?: string[] }; + @ApiProperty({ example: null, nullable: true }) + cancelledBy!: string | null; +} + +class WorkspaceInvitationWithUrlDto extends WorkspaceInvitationDto { + @ApiProperty({ example: 'https://app.taskly.dev/invite/token_abc', nullable: true }) + inviteUrl!: string | null; +} + +class BoardDto { + @ApiProperty({ example: 'board_123' }) + id!: string; + + @ApiProperty({ example: 'ws_123' }) + workspaceId!: string; + + @ApiProperty({ example: 'Roadmap' }) + title!: string; + + @ApiProperty({ example: 'Product roadmap' }) + description!: string; + + @ApiProperty({ type: 'object', nullable: true }) + background!: BoardBackground | null; + + @ApiProperty({ example: 1 }) + order!: number; + + @ApiProperty({ example: false }) + isArchived!: boolean; + + @ApiProperty({ example: null, nullable: true }) + archivedAt!: string | null; + + @ApiProperty({ example: '2024-01-01T10:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2024-01-02T10:00:00.000Z' }) + updatedAt!: string; +} + +class CreateWorkspaceDto { + @ApiProperty({ required: false, example: 'Acme Workspace' }) + title?: string; + + @ApiProperty({ required: false, example: 'Main team workspace' }) + description?: string; +} + +class PatchWorkspaceDto { + @ApiProperty({ required: false, example: 'Acme Workspace' }) + title?: string; + + @ApiProperty({ required: false, example: 'Main team workspace' }) + description?: string; +} -type CreateInvitationDto = { role?: WorkspaceRole; expiresInDays?: number }; +class AddMemberDto { + @ApiProperty({ required: false, example: 'usr_123' }) + userId?: string; + + @ApiProperty({ required: false, enum: ['admin', 'maintainer', 'editor', 'viewer'] }) + role?: WorkspaceRole; +} + +class PatchMemberDto { + @ApiProperty({ required: false, enum: ['admin', 'maintainer', 'editor', 'viewer'] }) + role?: WorkspaceRole; +} + +class CreateBoardDto { + @ApiProperty({ required: false, example: 'Roadmap' }) + title?: string; + + @ApiProperty({ + required: false, + description: 'Board background as object or string.', + type: 'object', + }) + background?: BoardBackground | string | null; + + @ApiProperty({ + required: false, + description: 'Legacy background color (hex or CSS).', + example: '#FFAA00', + }) + backgroundColor?: string | null; +} + +class ReorderBoardsDto { + @ApiProperty({ required: false, type: [String], example: ['board_1', 'board_2'] }) + boardIds?: string[]; +} + +function parseBoardBackgroundInput(input: unknown): BoardBackground | null { + if (input === null || input === undefined) return null; + if (typeof input === 'string') { + const value = input.trim(); + if (!value) return null; + return { type: 'color', value }; + } + if (typeof input !== 'object') return null; + const data = input as { type?: unknown; value?: unknown }; + if (data.type === 'color' || data.type === 'gradient') { + if (typeof data.value !== 'string') return null; + const value = data.value.trim(); + if (!value) return null; + return { type: data.type, value }; + } + if (data.type === 'image') { + if (typeof data.value !== 'object' || !data.value) return null; + const value = data.value as Record; + const id = typeof value.id === 'string' ? value.id : ''; + const url = typeof value.url === 'string' ? value.url : ''; + const thumbUrl = typeof value.thumbUrl === 'string' ? value.thumbUrl : ''; + if (!id || !url || !thumbUrl) return null; + return { + type: 'image', + value: { + source: value.source === 'unsplash' ? 'unsplash' : 'unsplash', + id, + url, + thumbUrl, + blurHash: typeof value.blurHash === 'string' ? value.blurHash : null, + color: typeof value.color === 'string' ? value.color : null, + authorName: typeof value.authorName === 'string' ? value.authorName : null, + authorUrl: typeof value.authorUrl === 'string' ? value.authorUrl : null, + }, + }; + } + return null; +} + +class CreateInvitationDto { + @ApiProperty({ required: false, enum: ['admin', 'maintainer', 'editor', 'viewer'] }) + role?: WorkspaceRole; + + @ApiProperty({ required: false, example: 7 }) + expiresInDays?: number; +} function asRole(x: unknown): WorkspaceRole | null { if (x === 'admin' || x === 'maintainer' || x === 'editor' || x === 'viewer') return x; @@ -45,6 +301,8 @@ function addDaysIso(days: number): string { } @UseGuards(FirebaseAuthGuard) +@ApiTags('workspaces') +@ApiBearerAuth('bearer') @Controller('/api/workspaces') export class WorkspaceController { constructor( @@ -55,6 +313,7 @@ export class WorkspaceController { ) {} @Get() + @ApiOkResponse({ type: [WorkspaceListItemDto] }) async list(@CurrentUser() user: UserModel) { // Any member role includes workspace.meta.read per mapping. const workspaces = await this.workspaces.listWorkspacesForUser(user.id); @@ -68,6 +327,8 @@ export class WorkspaceController { } @Post() + @ApiBody({ type: CreateWorkspaceDto }) + @ApiOkResponse({ type: WorkspaceDto }) async create(@CurrentUser() user: UserModel, @Body() body: CreateWorkspaceDto) { const title = (body.title ?? '').trim(); if (!title) throw new BadRequestException('title is required'); @@ -79,6 +340,7 @@ export class WorkspaceController { } @Get('/:workspaceId') + @ApiOkResponse({ type: WorkspaceDetailsDto }) async get(@CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string) { await this.access.requirePermission(workspaceId, user.id, 'workspace.meta.read'); @@ -104,6 +366,8 @@ export class WorkspaceController { } @Patch('/:workspaceId') + @ApiBody({ type: PatchWorkspaceDto }) + @ApiOkResponse({ type: WorkspaceDto }) async patch( @CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string, @@ -125,6 +389,12 @@ export class WorkspaceController { } @Delete('/:workspaceId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async remove(@CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string) { await this.access.requirePermission(workspaceId, user.id, 'workspace.meta.write'); await this.workspaces.archiveWorkspace(workspaceId); @@ -133,12 +403,15 @@ export class WorkspaceController { // Members @Get('/:workspaceId/members') + @ApiOkResponse({ type: [WorkspaceMemberDto] }) async members(@CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string) { await this.access.requirePermission(workspaceId, user.id, 'workspace.members.read'); return await this.workspaces.listMembers(workspaceId); } @Post('/:workspaceId/members') + @ApiBody({ type: AddMemberDto }) + @ApiOkResponse({ type: WorkspaceMemberDto }) async addMember( @CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string, @@ -166,6 +439,8 @@ export class WorkspaceController { } @Patch('/:workspaceId/members/:userId') + @ApiBody({ type: PatchMemberDto }) + @ApiOkResponse({ type: WorkspaceMemberDto }) async patchMember( @CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string, @@ -197,6 +472,12 @@ export class WorkspaceController { } @Delete('/:workspaceId/members/:userId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async removeMember( @CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string, @@ -218,12 +499,15 @@ export class WorkspaceController { // Boards @Get('/:workspaceId/boards') + @ApiOkResponse({ type: [BoardDto] }) async boards(@CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string) { await this.access.requirePermission(workspaceId, user.id, 'workspace.boards.read'); return await this.boardsService.listBoardsForWorkspace(workspaceId); } @Post('/:workspaceId/boards') + @ApiBody({ type: CreateBoardDto }) + @ApiOkResponse({ type: BoardDto }) async createBoard( @CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string, @@ -232,15 +516,27 @@ export class WorkspaceController { await this.access.requirePermission(workspaceId, user.id, 'workspace.boards.write'); const title = (body.title ?? '').trim(); if (!title) throw new BadRequestException('title is required'); - const backgroundColor = body.backgroundColor ?? null; + const legacyColor = (body.backgroundColor ?? '').trim(); + const background: BoardBackground | null = Object.prototype.hasOwnProperty.call(body, 'background') + ? parseBoardBackgroundInput(body.background) + : legacyColor + ? ({ type: 'color', value: legacyColor } satisfies BoardBackground) + : null; return await this.boardsService.createBoard({ workspaceId, title, - background: backgroundColor, + background, }); } @Patch('/:workspaceId/boards/order') + @ApiBody({ type: ReorderBoardsDto }) + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async reorderBoards( @CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string, @@ -263,6 +559,12 @@ export class WorkspaceController { } @Delete('/:workspaceId/boards/:boardId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async removeBoard( @CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string, @@ -276,6 +578,8 @@ export class WorkspaceController { // Invitations @Post('/:workspaceId/invitations') + @ApiBody({ type: CreateInvitationDto }) + @ApiOkResponse({ type: WorkspaceInvitationWithUrlDto }) async createInvitation( @CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string, @@ -306,12 +610,19 @@ export class WorkspaceController { } @Get('/:workspaceId/invitations') + @ApiOkResponse({ type: [WorkspaceInvitationDto] }) async listInvitations(@CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string) { await this.access.requirePermission(workspaceId, user.id, 'workspace.members.write'); return await this.workspaces.listPendingInvitations(workspaceId); } @Delete('/:workspaceId/invitations/:invitationId') + @ApiOkResponse({ + schema: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + }) async cancelInvitation( @CurrentUser() user: UserModel, @Param('workspaceId') workspaceId: string, @@ -325,6 +636,7 @@ export class WorkspaceController { } @Post('/invitations/:token/accept') + @ApiOkResponse({ type: WorkspaceMemberDto }) async accept(@CurrentUser() user: UserModel, @Param('token') token: string) { const t = (token ?? '').trim(); if (!t) throw new BadRequestException('token is required'); @@ -336,6 +648,7 @@ export class WorkspaceController { } @Post('/invitations/:token/decline') + @ApiOkResponse({ type: WorkspaceInvitationDto }) async decline(@CurrentUser() user: UserModel, @Param('token') token: string) { const t = (token ?? '').trim(); if (!t) throw new BadRequestException('token is required'); diff --git a/apps/api/src/workspace/workspace.module.ts b/apps/api/src/workspace/workspace.module.ts index bd952e7..e8f2e01 100644 --- a/apps/api/src/workspace/workspace.module.ts +++ b/apps/api/src/workspace/workspace.module.ts @@ -1,3 +1,19 @@ +/** + * Workspace Module + * + * Provides REST endpoints for workspace management. + * + * Features: + * - Workspace CRUD (create, read, update, archive) + * - Member management (add, remove, list, role assignment) + * - Invitation system (create, list, accept, decline) + * - Board management within workspaces + * - Permission checking via WorkspaceAccessService + * + * All endpoints require Firebase authentication. + * Access is controlled by workspace membership and role-based permissions. + */ + import { Module } from '@nestjs/common'; import { WorkspaceController } from './workspace.controller.js'; import { WorkspaceAccessService } from './workspace-access.service.js'; diff --git a/apps/web/src/app/(app)/dashboard/boards/[boardId]/board-client.tsx b/apps/web/src/app/(app)/dashboard/boards/[boardId]/board-client.tsx index eae4b9c..21e500f 100644 --- a/apps/web/src/app/(app)/dashboard/boards/[boardId]/board-client.tsx +++ b/apps/web/src/app/(app)/dashboard/boards/[boardId]/board-client.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { api } from '@/app/trpc'; +import type { BoardBackground } from '@taskly/trpc'; +import { getBoardBackgroundStyle } from '@/components/board/board-background'; import { DndContext, DragEndEvent, @@ -31,13 +33,12 @@ import { DialogTrigger, Input, Textarea, - Avatar, - AvatarFallback, cn, useToast, } from '@taskly/ui'; import { useWorkspaceUI } from '@/components/workspace/workspace-ui-provider'; -import { MoreHorizontal, Plus, CheckSquare, Paperclip, MessageSquare, GripVertical, Calendar, ScrollText, Clock } from 'lucide-react'; +import { UserAvatar } from '@/components/user/user-avatar'; +import { MoreHorizontal, Plus, CheckSquare, Paperclip, MessageSquare, GripVertical, Calendar, ScrollText, Clock, Paintbrush } from 'lucide-react'; import TicketDialogV2 from '@/components/ticket/ticket-dialog-v2'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -140,6 +141,17 @@ function formatBoardActivityType(input: { type: string; ticketTitle?: string | n } } +function boardBackgroundKey(bg: BoardBackground): string { + if (bg.type === 'image') return `image:${bg.value.id}`; + return `${bg.type}:${bg.value}`; +} + +function isSameBackground(a: BoardBackground, b: BoardBackground | null): boolean { + if (!b || a.type !== b.type) return false; + if (a.type === 'image' && b.type === 'image') return a.value.id === b.value.id; + return a.value === b.value; +} + type AnyLabel = { id: string; name: string; color: string }; type TicketLike = { id: string; @@ -157,9 +169,10 @@ function SortableBoardTicket({ t, labelMap, onOpen, - formatUserPrimary, - formatUserSecondary, - initialsForUser, + formatUserPrimary: _formatUserPrimary, + formatUserSecondary: _formatUserSecondary, + initialsForUser: _initialsForUser, + userById, }: { t: TicketLike; labelMap: Map; @@ -167,6 +180,7 @@ function SortableBoardTicket({ formatUserPrimary: (userId: string) => string; formatUserSecondary: (userId: string) => string; initialsForUser: (userId: string) => string; + userById: Map; }) { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: dndTicketId(t.id), @@ -274,13 +288,11 @@ function SortableBoardTicket({ {t.assigneeIds && t.assigneeIds.length > 0 && (
{t.assigneeIds.slice(0, 3).map((aid) => ( - - {initialsForUser(aid)} - + /> ))} {t.assigneeIds.length > 3 && (
@@ -296,7 +308,7 @@ function SortableBoardTicket({ } function SortableBoardColumn({ - boardId, + boardId: _boardId, col, colTickets, isEditingThisColumn, @@ -325,6 +337,7 @@ function SortableBoardColumn({ formatUserPrimary, formatUserSecondary, initialsForUser, + userById, }: { boardId: string; col: ColumnLike; @@ -355,6 +368,7 @@ function SortableBoardColumn({ formatUserPrimary: (userId: string) => string; formatUserSecondary: (userId: string) => string; initialsForUser: (userId: string) => string; + userById: Map; }) { const { setNodeRef: setDropRef } = useDroppable({ id: dndColumnDropId(col.id), @@ -469,6 +483,7 @@ function SortableBoardColumn({ formatUserPrimary={formatUserPrimary} formatUserSecondary={formatUserSecondary} initialsForUser={initialsForUser} + userById={userById} /> ))} @@ -516,6 +531,23 @@ export default function BoardClient({ boardId }: { boardId: string }) { { enabled: boardActivityOpen }, ); + const [backgroundPickerOpen, setBackgroundPickerOpen] = React.useState(false); + const [backgroundTab, setBackgroundTab] = React.useState<'color' | 'gradient' | 'image'>('color'); + const [imageSearch, setImageSearch] = React.useState(''); + const imageSearchValue = imageSearch.trim(); + + const backgroundsQuery = api.boards.listBackgrounds.useInfiniteQuery( + { + type: backgroundTab, + limit: 12, + query: backgroundTab === 'image' && imageSearchValue ? imageSearchValue : undefined, + }, + { + enabled: backgroundPickerOpen, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + React.useEffect(() => { if (viewQuery.data?.board?.workspaceId) { setSelectedWorkspaceId(viewQuery.data.board.workspaceId); @@ -593,9 +625,10 @@ export default function BoardClient({ boardId }: { boardId: string }) { }); const board = viewQuery.data?.board; + const canEditBackground = viewQuery.data?.permissions?.canBackgroundWrite ?? false; const serverColumns = React.useMemo(() => viewQuery.data?.columns ?? [], [viewQuery.data?.columns]); const serverTickets = React.useMemo(() => viewQuery.data?.tickets ?? [], [viewQuery.data?.tickets]); - const boardLabels = labelsQuery.data ?? []; + const boardLabels = React.useMemo(() => labelsQuery.data ?? [], [labelsQuery.data]); const [columnsState, setColumnsState] = React.useState(serverColumns); const [ticketsState, setTicketsState] = React.useState(serverTickets); @@ -619,7 +652,7 @@ export default function BoardClient({ boardId }: { boardId: string }) { return new Map((assigneeUsersQuery.data ?? []).map((u) => [u.id, u])); }, [assigneeUsersQuery.data]); - const boardActivityItems = boardActivityQuery.data?.items ?? []; + const boardActivityItems = React.useMemo(() => boardActivityQuery.data?.items ?? [], [boardActivityQuery.data?.items]); const activityActorIds = React.useMemo(() => { const ids = boardActivityItems.map((i) => i.actorId).filter(Boolean) as string[]; return Array.from(new Set(ids)); @@ -853,9 +886,12 @@ export default function BoardClient({ boardId }: { boardId: string }) { return
Board not found.
; } + const currentBackground = board.background ?? null; + const backgroundItems = backgroundsQuery.data?.pages.flatMap((p) => p.items) ?? []; + return ( -
-
+
+
{isEditingBoardTitle ? ( @@ -900,6 +936,99 @@ export default function BoardClient({ boardId }: { boardId: string }) {
+ + + + + + + Board background + +
+ {(['color', 'gradient', 'image'] as const).map((tab) => ( + + ))} + +
+ + {backgroundTab === 'image' && ( + setImageSearch(e.target.value)} + placeholder="Search Unsplash" + /> + )} + + {backgroundsQuery.isLoading ? ( +
Loading backgrounds…
+ ) : backgroundItems.length === 0 ? ( +
No background found.
+ ) : ( +
+ {backgroundItems.map((bg) => { + const selected = isSameBackground(bg, currentBackground); + return ( +
+ )} + + {backgroundsQuery.hasNextPage && ( +
+ +
+ )} +
+
+
+ {/* tRPC connection test panel */}

Test tRPC

{hello.isLoading &&

Chargement…

} diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index e82e0cd..96d773b 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -1,14 +1,48 @@ +/** + * Application Providers Wrapper + * + * Combines all essential React Context providers into a single component. + * Must be placed in the root layout to enable tRPC, authentication, and notifications. + * + * Provider hierarchy (outer to inner): + * 1. TRPCProvider: Backend API communication + * 2. AuthProvider: User authentication state + * 3. Toaster: Global notifications/toast container + */ + 'use client'; import { Toaster } from '@taskly/ui'; import { AuthProvider } from '@/auth/auth-provider'; import { TRPCProvider } from './trpc-provider'; +/** + * Root providers component. + * + * Wraps children with all necessary providers for the application to function. + * Place this in the root layout wrapper element. + * + * @param props - Component props + * @param props.children - Application components to wrap + * + * @example + * ```tsx + * // In root layout + * export default function RootLayout() { + * return ( + * + * + * + * ); + * } + * ``` + */ export function Providers({ children }: { children: React.ReactNode }) { return ( {children} + {/* Global toast notifications container */} diff --git a/apps/web/src/app/trpc-provider.tsx b/apps/web/src/app/trpc-provider.tsx index d630f54..ffa40f6 100644 --- a/apps/web/src/app/trpc-provider.tsx +++ b/apps/web/src/app/trpc-provider.tsx @@ -1,3 +1,15 @@ +/** + * tRPC Provider Configuration + * + * Sets up tRPC client with: + * - HTTP batch link for efficient request batching + * - Firebase JWT authentication via Authorization header + * - SuperJSON transformer for complex type serialization + * - React Query integration for state management + * + * Automatically injects Firebase ID token into all tRPC requests. + */ + 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -7,21 +19,52 @@ import { useState } from 'react'; import { api } from './trpc'; import { getFirebaseAuth } from '@/lib/firebase/firebase-client'; +/** + * Gets the base URL for tRPC API calls. + * Respects NEXT_PUBLIC_API_URL environment variable. + * + * @returns API base URL (e.g., 'http://localhost:4000') + */ function getBaseUrl() { - // Browser -> env public + // Browser -> use env public variable if (typeof window !== 'undefined') return process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; - // SSR (not needed for this MVP), keep default + // SSR (not needed for this MVP) return process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; } +/** + * tRPC Provider Component + * + * Configures the tRPC client with authentication and sets up React Query. + * Automatically attaches Firebase ID tokens to all requests. + * + * @param props - Component props + * @param props.children - Components to provide tRPC context to + * + * @example + * ```tsx + * // In root layout + * + * + * + * + * + * ``` + */ export function TRPCProvider({ children }: { children: React.ReactNode }) { + // Initialize React Query client (persistent across renders) const [queryClient] = useState(() => new QueryClient()); + + // Initialize tRPC client with HTTP batch link and auth headers const [trpcClient] = useState(() => api.createClient({ links: [ httpBatchLink({ + // URL to the tRPC endpoint on the API server url: `${getBaseUrl()}/trpc`, + // Transformer for serializing complex types (Date, Map, Set, etc.) transformer: superjson, + // Dynamically add Firebase JWT to Authorization header headers: async () => { const auth = getFirebaseAuth(); const token = await auth?.currentUser?.getIdToken(); diff --git a/apps/web/src/app/trpc.ts b/apps/web/src/app/trpc.ts index 89d5961..a658ca3 100644 --- a/apps/web/src/app/trpc.ts +++ b/apps/web/src/app/trpc.ts @@ -1,6 +1,26 @@ +/** + * tRPC React Client + * + * Typed tRPC client configured for the Taskly API. + * Provides end-to-end type safety for frontend-backend communication. + * + * Usage: + * ```tsx + * const { data, isLoading, error } = api.users.getById.useQuery('userId'); + * await api.workspaces.createWorkspace.useMutation(); + * ``` + */ + import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@taskly/trpc'; +/** + * Typed tRPC React client instance. + * Configured with the AppRouter type from @taskly/trpc. + * + * Provides hooks for queries, mutations, and subscriptions + * with full TypeScript support. + */ export const api = createTRPCReact(); diff --git a/apps/web/src/auth/auth-provider.tsx b/apps/web/src/auth/auth-provider.tsx index 6e20f14..bd825be 100644 --- a/apps/web/src/auth/auth-provider.tsx +++ b/apps/web/src/auth/auth-provider.tsx @@ -1,3 +1,16 @@ +/** + * Firebase Authentication Context Provider + * + * Provides authentication state and operations to the entire application. + * Manages: + * - Current user state (Firebase User object) + * - Loading state during auth state initialization + * - Sign in, registration, and logout operations + * + * Must wrap the entire app (typically in root layout). + * Use the `useAuth()` hook to access auth context anywhere in the component tree. + */ + 'use client'; import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; @@ -5,20 +18,52 @@ import type { User } from 'firebase/auth'; import { createUserWithEmailAndPassword, onAuthStateChanged, signInWithEmailAndPassword, signOut } from 'firebase/auth'; import { getFirebaseAuth } from '@/lib/firebase/firebase-client'; +/** + * Shape of the authentication context value. + * Contains user state, loading flag, and auth operations. + */ type AuthContextValue = { + /** Current authenticated user from Firebase, or null if not logged in */ user: User | null; + /** True while initial auth state is being loaded */ loading: boolean; + /** Sign in with email and password */ signInWithEmailPassword: (email: string, password: string) => Promise; + /** Register new user with email and password */ registerWithEmailPassword: (email: string, password: string) => Promise; + /** Sign out current user */ logout: () => Promise; }; +/** React Context for authentication state */ const AuthContext = createContext(null); +/** + * Authentication Provider Component + * + * Initializes Firebase auth listener on mount and provides auth context to children. + * Handles the initial auth state resolution for hydration. + * + * @param props - Component props + * @param props.children - Components to wrap with auth context + * + * @example + * ```tsx + * // In root layout + * export default function RootLayout() { + * return ( + * + * + * + * ); + * } + * ``` + */ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + // Subscribe to Firebase auth state changes on mount useEffect(() => { const auth = getFirebaseAuth(); if (!auth) { @@ -26,27 +71,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return; } + // Listen for auth state changes (fires immediately with current state) const unsub = onAuthStateChanged(auth, (u) => { setUser(u); setLoading(false); }); + + // Cleanup: unsubscribe on unmount return () => unsub(); }, []); + // Memoize context value to prevent unnecessary re-renders const value = useMemo( () => ({ user, loading, + /** + * Sign in existing user with email and password. + * @throws Error if Firebase Auth is not configured + */ signInWithEmailPassword: async (email, password) => { const auth = getFirebaseAuth(); - if (!auth) throw new Error('Firebase Auth n’est pas configuré (env manquantes).'); + if (!auth) throw new Error("Firebase Auth n'est pas configuré (env manquantes)."); await signInWithEmailAndPassword(auth, email, password); }, + /** + * Register new user with email and password. + * @throws Error if Firebase Auth is not configured + */ registerWithEmailPassword: async (email, password) => { const auth = getFirebaseAuth(); - if (!auth) throw new Error('Firebase Auth n’est pas configuré (env manquantes).'); + if (!auth) throw new Error("Firebase Auth n'est pas configuré (env manquantes)."); await createUserWithEmailAndPassword(auth, email, password); }, + /** + * Sign out current user. + * Does nothing if Firebase Auth is not configured. + */ logout: async () => { const auth = getFirebaseAuth(); if (!auth) return; @@ -59,6 +120,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return {children}; } +/** + * Hook to access authentication context. + * + * Provides access to current user, loading state, and auth operations. + * Must be used within an component. + * + * @returns Authentication context value + * @throws Error if used outside + * + * @example + * ```tsx + * export function MyComponent() { + * const { user, loading, logout } = useAuth(); + * + * if (loading) return ; + * if (!user) return ; + * + * return ; + * } + * ``` + */ export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth must be used within '); diff --git a/apps/web/src/auth/require-auth.tsx b/apps/web/src/auth/require-auth.tsx index 308567e..71381ba 100644 --- a/apps/web/src/auth/require-auth.tsx +++ b/apps/web/src/auth/require-auth.tsx @@ -1,21 +1,59 @@ +/** + * Authentication Guard Component + * + * Protects components by requiring an authenticated user. + * Redirects unauthenticated users to the login page with a return URL. + * + * Shows nothing (null) while auth state is loading or user is not authenticated. + * Once user is verified, renders children. + * + * @example + * ```tsx + * // In a layout or page + * export default function ProtectedPage() { + * return ( + * + * + * + * ); + * } + * ``` + */ + 'use client'; import { useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { useAuth } from './auth-provider'; +/** + * Component that requires authentication to render children. + * + * Behavior: + * - Loading state: renders nothing + * - Unauthenticated: redirects to /login with `next` query param for post-login redirect + * - Authenticated: renders children + * + * @param props - Component props + * @param props.children - Content to render if user is authenticated + * @returns Children if authenticated, null if loading or unauthenticated (which triggers redirect) + */ export function RequireAuth({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); const router = useRouter(); const pathname = usePathname(); + // Redirect to login if auth is done loading and user is not authenticated useEffect(() => { if (!loading && !user) { + // Preserve current URL to redirect after login const next = encodeURIComponent(pathname ?? '/'); router.replace(`/login?next=${next}`); } }, [loading, user, router, pathname]); + // Don't render anything while loading or if user is not authenticated + // (redirect will happen via useEffect) if (loading) return null; if (!user) return null; return children; diff --git a/apps/web/src/components/board/board-background.tsx b/apps/web/src/components/board/board-background.tsx new file mode 100644 index 0000000..b4d0f60 --- /dev/null +++ b/apps/web/src/components/board/board-background.tsx @@ -0,0 +1,86 @@ +/** + * Board Background Style Utilities + * + * Converts board background data into React CSS properties. + * Supports color, gradient, and image (Unsplash) backgrounds. + * + * Useful for applying backgrounds to board containers, preview images, etc. + */ + +import type { BoardBackground } from '@taskly/trpc'; +import type React from 'react'; + +/** + * Options for background style generation + */ +type BoardBackgroundStyleOptions = { + /** Fallback color if background is null (default: dark gray '#1d2125') */ + fallback?: string; + /** Use thumbnail URL for images instead of full resolution (useful for previews) */ + preferThumb?: boolean; +}; + +/** + * Converts a BoardBackground object into React CSSProperties. + * Handles all background types: color, gradient, and image. + * + * @param background - Board background object or null/undefined + * @param options - Configuration options + * @returns React CSSProperties object ready for inline styles + * + * @example + * ```tsx + * // Color background + * const style = getBoardBackgroundStyle({ type: 'color', value: '#0EA5E9' }); + * // Returns: { backgroundColor: '#0EA5E9' } + * + * // Gradient background + * const style = getBoardBackgroundStyle({ + * type: 'gradient', + * value: 'linear-gradient(135deg, #0EA5E9 0%, #6366F1 100%)' + * }); + * + * // Image background (with thumbnail preference) + * const style = getBoardBackgroundStyle( + * { type: 'image', value: { url: '...', thumbUrl: '...' } }, + * { preferThumb: true } + * ); + * ``` + */ +export function getBoardBackgroundStyle( + background: BoardBackground | null | undefined, + options: BoardBackgroundStyleOptions = {}, +): React.CSSProperties { + const fallback = options.fallback ?? '#1d2125'; + + // No background: use fallback color + if (!background) { + return { backgroundColor: fallback }; + } + + // Solid color background + if (background.type === 'color') { + return { backgroundColor: background.value }; + } + + // CSS gradient background + if (background.type === 'gradient') { + return { + backgroundColor: fallback, + backgroundImage: background.value, + backgroundSize: 'cover', + backgroundPosition: 'center', + }; + } + + // Image background (Unsplash or other source) + const imageUrl = options.preferThumb ? background.value.thumbUrl : background.value.url; + return { + backgroundColor: fallback, + backgroundImage: `url("${imageUrl}")`, + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + }; +} + diff --git a/apps/web/src/components/layout/navbar.tsx b/apps/web/src/components/layout/navbar.tsx index e3fe7ed..a8b62ab 100644 --- a/apps/web/src/components/layout/navbar.tsx +++ b/apps/web/src/components/layout/navbar.tsx @@ -1,3 +1,20 @@ +/** + * Application Navigation Bar Component + * + * Top-level navigation bar displayed across all authenticated pages. + * Features: + * - Workspace switcher with dropdown + * - Search functionality + * - Notifications bell with dropdown list + * - User profile menu (avatar, profile edit, logout) + * - Workspace creation and board management shortcuts + * + * Contains multiple sub-dialogs: + * - Workspace creation dialog + * - User profile edit dialog + * - Workspace invitations dialog + */ + "use client" import * as React from "react" @@ -18,22 +35,24 @@ import { DialogTitle, DialogTrigger, Textarea, + useToast, } from "@taskly/ui" -import { Grid, Search, Bell, HelpCircle, Plus } from "lucide-react" +import { Grid, Search, Bell, HelpCircle, Plus, Upload } from "lucide-react" import { api } from "@/app/trpc" import { useWorkspaceUI } from "@/components/workspace/workspace-ui-provider" import { usePathname, useRouter } from "next/navigation" import { useAuth } from "@/auth/auth-provider" import { getFirebaseAuth } from "@/lib/firebase/firebase-client" import { updateProfile } from "firebase/auth" - -function initialsFrom(s: string) { - const parts = (s ?? "").trim().split(/\s+/).filter(Boolean) - const a = parts[0]?.[0] ?? "?" - const b = parts.length > 1 ? parts[parts.length - 1]?.[0] : "" - return (a + b).toUpperCase() -} - +import { UserAvatar } from "@/components/user/user-avatar" +import { getBoardBackgroundStyle } from "@/components/board/board-background" + +/** + * Formats notification timestamp for display. + * Handles both ISO strings and millisecond timestamps. + * @param input - Object with createdAt (ISO string) or createdAtMs (number) + * @returns Formatted local time string or empty string if invalid + */ function formatNotificationTime(input: { createdAt?: string; createdAtMs?: number }) { const ms = Number.isFinite(input.createdAtMs) ? (input.createdAtMs as number) : undefined const date = ms ? new Date(ms) : input.createdAt ? new Date(input.createdAt) : null @@ -46,6 +65,7 @@ export function Navbar() { const pathname = usePathname() const isDashboardOverview = pathname === "/dashboard" || pathname === "/workspaces" const utils = api.useUtils() + const { toast } = useToast() const { workspaces, selectedWorkspaceId, setSelectedWorkspaceId } = useWorkspaceUI() const selectedWorkspace = workspaces.find((w) => w.id === selectedWorkspaceId) ?? null const { user: firebaseUser, logout } = useAuth() @@ -57,6 +77,32 @@ export function Navbar() { }, }) + const setAvatarBackground = api.users.avatar.setInitialsBackground.useMutation({ + onSuccess: async () => { + await utils.users.me.invalidate() + await utils.users.byIds.invalidate() + await utils.users.byId.invalidate() + }, + }) + + const clearAvatar = api.users.avatar.clear.useMutation({ + onSuccess: async () => { + await utils.users.me.invalidate() + await utils.users.byIds.invalidate() + await utils.users.byId.invalidate() + }, + }) + + const createAvatarUpload = api.users.avatar.createUpload.useMutation() + const completeAvatarUpload = api.users.avatar.completeUpload.useMutation({ + onSuccess: async () => { + await utils.users.me.invalidate() + await utils.users.byIds.invalidate() + await utils.users.byId.invalidate() + toast({ title: "Photo updated" }) + }, + }) + const deleteMe = api.users.deleteMe.useMutation({ onSuccess: async () => { await logout() @@ -73,7 +119,22 @@ export function Navbar() { const [description, setDescription] = React.useState("") const [displayName, setDisplayName] = React.useState("") - const [photoURL, setPhotoURL] = React.useState("") + const [avatarOpen, setAvatarOpen] = React.useState(false) + const [avatarTab, setAvatarTab] = React.useState<"color" | "gradient" | "image" | "upload">("color") + const [avatarSearch, setAvatarSearch] = React.useState("") + + const avatarSearchValue = avatarSearch.trim() + const avatarBackgroundsQuery = api.boards.listBackgrounds.useInfiniteQuery( + { + type: avatarTab === "upload" ? "color" : avatarTab, + limit: 12, + query: avatarTab === "image" && avatarSearchValue ? avatarSearchValue : undefined, + }, + { + enabled: avatarOpen && avatarTab !== "upload", + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ) const notificationsQuery = api.notifications.list.useQuery({ limit: 6 }) const unreadCountQuery = api.notifications.unreadCount.useQuery() @@ -113,8 +174,7 @@ export function Navbar() { React.useEffect(() => { setDisplayName(firebaseUser?.displayName ?? "") - setPhotoURL(firebaseUser?.photoURL ?? "") - }, [firebaseUser?.uid, firebaseUser?.displayName, firebaseUser?.photoURL]) + }, [firebaseUser?.uid, firebaseUser?.displayName]) const goToWorkspace = (workspaceId: string) => { setSelectedWorkspaceId(workspaceId) @@ -142,6 +202,52 @@ export function Navbar() { const [createBoardOpen, setCreateBoardOpen] = React.useState(false) const [boardTitle, setBoardTitle] = React.useState("") + const me = meQuery.data + const avatarBackgroundItems = avatarBackgroundsQuery.data?.pages.flatMap((p) => p.items) ?? [] + const avatarUploading = createAvatarUpload.isPending || completeAvatarUpload.isPending + + const onUploadAvatarFile = React.useCallback( + async (file: File) => { + let objectPath: string | null = null + try { + toast({ title: "Uploading..." }) + const res = await createAvatarUpload.mutateAsync({ + filename: file.name, + contentType: file.type || "application/octet-stream", + resumable: false, + }) + objectPath = res.objectPath + const putRes = await fetch(res.upload.url, { + method: res.upload.method, + headers: res.upload.headers, + body: file, + }) + if (!putRes.ok) { + let bodyText = "" + try { + bodyText = await putRes.text() + } catch { + // ignore + } + throw new Error(`GCS upload failed (${putRes.status}): ${bodyText || putRes.statusText || "Unknown error"}`) + } + await completeAvatarUpload.mutateAsync({ objectPath }) + setAvatarOpen(false) + } catch (err) { + const error = err as Error + toast({ + title: "Upload failed", + description: error.message.includes("client_email") + ? "GCS credentials not configured" + : error.message || + "Failed to upload file. If you see a CORS error in the console, update bucket CORS for your origin.", + variant: "destructive", + }) + } + }, + [createAvatarUpload, completeAvatarUpload, toast, setAvatarOpen], + ) + return (
{/* Left Section */} @@ -369,17 +475,10 @@ export function Navbar() { @@ -405,14 +504,126 @@ export function Navbar() {
ID: {firebaseUser?.uid ?? "-"}
+
+ +
+
Profile photo
+ + + + + + + Update avatar + + +
+ {(["color", "gradient", "image", "upload"] as const).map((tab) => ( + + ))} + +
+ + {avatarTab === "image" && ( + setAvatarSearch(e.target.value)} + placeholder="Search Unsplash" + /> + )} + + {avatarTab === "upload" ? ( +
+
+ Upload a square image (we’ll store it in GCS). +
+ +
+ ) : avatarBackgroundsQuery.isLoading ? ( +
Loading backgrounds…
+ ) : avatarBackgroundItems.length === 0 ? ( +
No background found.
+ ) : ( +
+ {avatarBackgroundItems.map((bg) => ( +
+ )} + + {avatarTab !== "upload" && avatarBackgroundsQuery.hasNextPage && ( +
+ +
+ )} +
+
+
+
+
Display name
setDisplayName(e.target.value)} placeholder="Display name" />
-
-
Photo URL
- setPhotoURL(e.target.value)} placeholder="https://..." /> -
Username
@@ -446,7 +657,6 @@ export function Navbar() { setDescription(me.description ?? "") } setDisplayName(firebaseUser?.displayName ?? "") - setPhotoURL(firebaseUser?.photoURL ?? "") }} > Reset @@ -470,7 +680,6 @@ export function Navbar() { try { await updateProfile(u, { displayName: displayName.trim() || null, - photoURL: photoURL.trim() || null, }) } catch { // ignore firebase update errors for now diff --git a/apps/web/src/components/layout/sidebar.spec.tsx b/apps/web/src/components/layout/sidebar.spec.tsx new file mode 100644 index 0000000..6cd043a --- /dev/null +++ b/apps/web/src/components/layout/sidebar.spec.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Sidebar } from '@/components/layout/sidebar'; + +const mockPush = vi.fn(); +let mockPathname = '/dashboard/boards/board-1'; +let workspaceState = { + workspaces: [ + { id: 'w1', title: 'Workspace A', description: '' }, + { id: 'w2', title: 'Workspace B', description: '' }, + ], + selectedWorkspaceId: 'w1' as string | null, + setSelectedWorkspaceId: vi.fn(), + isLoading: false, +}; + +const mockInvalidate = vi.fn(); +const mockMutate = vi.fn(); +const mockUseQuery = vi.fn(() => ({ data: [] })); + +vi.mock('next/navigation', () => ({ + usePathname: () => mockPathname, + useRouter: () => ({ push: mockPush }), +})); + +vi.mock('next/link', async () => { + const React = await import('react'); + return { + default: ({ href, children }: { href: string; children: React.ReactNode }) => + React.createElement('a', { href }, children), + }; +}); + +vi.mock('@/components/workspace/workspace-ui-provider', () => ({ + useWorkspaceUI: () => workspaceState, +})); + +vi.mock('@/app/trpc', () => ({ + api: { + useUtils: () => ({ + workspaces: { + boards: { + list: { invalidate: mockInvalidate }, + }, + }, + }), + workspaces: { + boards: { + list: { + useQuery: vi.fn(() => mockUseQuery()), + }, + create: { + useMutation: () => ({ mutate: mockMutate, isPending: false }), + }, + }, + }, + }, +})); + +describe('Sidebar', () => { + beforeEach(() => { + mockPush.mockClear(); + workspaceState = { + workspaces: [ + { id: 'w1', title: 'Workspace A', description: '' }, + { id: 'w2', title: 'Workspace B', description: '' }, + ], + selectedWorkspaceId: 'w1', + setSelectedWorkspaceId: vi.fn(), + isLoading: false, + }; + mockPathname = '/dashboard/boards/board-1'; + }); + + it('shows workspace navigation when not collapsed', () => { + render(); + + expect(screen.getByText('Boards')).toBeInTheDocument(); + expect(screen.getByText('Members')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText(/Your boards/i)).toBeInTheDocument(); + }); + + it('hides labels when collapsed', () => { + render(); + + expect(screen.queryByText('Boards')).not.toBeInTheDocument(); + expect(screen.queryByText(/Your boards/i)).not.toBeInTheDocument(); + }); + + it('navigates to the selected workspace', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /workspace b/i })); + + expect(workspaceState.setSelectedWorkspaceId).toHaveBeenCalledWith('w2'); + expect(mockPush).toHaveBeenCalledWith('/dashboard/workspaces/w2'); + }); + + it('disables board creation when no workspace is selected', () => { + workspaceState = { + ...workspaceState, + selectedWorkspaceId: null, + }; + + render(); + + expect(screen.getByTitle('Select a workspace first')).toBeDisabled(); + }); +}); diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 1979e09..d88d088 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -1,3 +1,21 @@ +/** + * Application Sidebar Component + * + * Left navigation panel displaying: + * - Workspace switcher + * - Boards within the selected workspace + * - Quick navigation links (Members, Settings) + * - Create new board button + * + * Features: + * - Collapsible/expandable state for responsive layout + * - Board creation dialog + * - Workspace quick navigation + * - Active state highlighting + * + * Accepts controlled or uncontrolled collapse state. + */ + "use client" import * as React from "react" @@ -8,8 +26,14 @@ import { usePathname, useRouter } from "next/navigation" import { api } from "@/app/trpc" import { useWorkspaceUI } from "@/components/workspace/workspace-ui-provider" +/** + * Props for Sidebar component. + * Supports both controlled and uncontrolled collapse state. + */ interface SidebarProps extends React.HTMLAttributes { + /** Controlled collapse state (overrides internal state if provided) */ isCollapsed?: boolean + /** Callback when collapse state changes */ onCollapse?: (collapsed: boolean) => void } @@ -48,13 +72,16 @@ export function Sidebar({ className, isCollapsed: controlledCollapsed, onCollaps const [newBoardTitle, setNewBoardTitle] = React.useState("") return ( -
+
{/* Collapse Toggle */} -
+