Skip to content
Merged

Dev #102

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
371 changes: 322 additions & 49 deletions README.md

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand All @@ -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:*",
Expand All @@ -27,14 +28,16 @@
"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",
"@types/cors": "^2.8.19",
"@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",
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {}
Expand Down
50 changes: 50 additions & 0 deletions apps/api/src/board/board-access.service.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,87 @@
/**
* 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(
private readonly boards: BoardsService,
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<BoardModel> {
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<WorkspaceRole> {
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}`);
}
Expand Down
84 changes: 84 additions & 0 deletions apps/api/src/board/board-backgrounds.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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',
},
});
});
});

Loading