From f47dd8bc92aef5e09f16f2e62f26c5ee3accc044 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 13:47:03 -0600 Subject: [PATCH] feat: add domains, ideas, layouts, widgets API modules - Add DomainsModule with full CRUD, search, and activity logging - Add IdeasModule with quick capture endpoint - Add LayoutsModule for user dashboard layouts - Add WidgetsModule for widget definitions (read-only) - Update ActivityService with domain/idea logging methods - Register all new modules in AppModule --- apps/api/prisma/schema.prisma | 3 + apps/api/src/activity/activity.service.ts | 144 ++++++++- .../activity/interfaces/activity.interface.ts | 2 +- apps/api/src/app.module.ts | 8 + apps/api/src/domains/domains.controller.ts | 104 +++++++ apps/api/src/domains/domains.module.ts | 14 + apps/api/src/domains/domains.service.ts | 199 ++++++++++++ apps/api/src/domains/dto/create-domain.dto.ts | 47 +++ apps/api/src/domains/dto/index.ts | 3 + apps/api/src/domains/dto/query-domains.dto.ts | 34 ++ apps/api/src/domains/dto/update-domain.dto.ts | 50 +++ apps/api/src/events/dto/update-event.dto.ts | 10 +- apps/api/src/events/events.service.ts | 5 +- apps/api/src/ideas/dto/capture-idea.dto.ts | 22 ++ apps/api/src/ideas/dto/create-idea.dto.ts | 56 ++++ apps/api/src/ideas/dto/index.ts | 4 + apps/api/src/ideas/dto/query-ideas.dto.ts | 52 ++++ apps/api/src/ideas/dto/update-idea.dto.ts | 58 ++++ apps/api/src/ideas/ideas.controller.ts | 130 ++++++++ apps/api/src/ideas/ideas.module.ts | 14 + apps/api/src/ideas/ideas.service.ts | 293 ++++++++++++++++++ apps/api/src/layouts/dto/create-layout.dto.ts | 31 ++ apps/api/src/layouts/dto/index.ts | 2 + apps/api/src/layouts/dto/update-layout.dto.ts | 34 ++ apps/api/src/layouts/layouts.controller.ts | 128 ++++++++ apps/api/src/layouts/layouts.module.ts | 13 + apps/api/src/layouts/layouts.service.ts | 185 +++++++++++ apps/api/src/layouts/types/widget.types.ts | 35 +++ apps/api/src/projects/projects.service.ts | 5 +- apps/api/src/tasks/tasks.service.ts | 3 +- apps/api/src/widgets/widgets.controller.ts | 39 +++ apps/api/src/widgets/widgets.module.ts | 13 + apps/api/src/widgets/widgets.service.ts | 59 ++++ apps/web/package.json | 5 +- apps/web/src/app/(authenticated)/page.tsx | 48 +++ .../dashboard/DomainOverviewWidget.tsx | 46 +++ .../dashboard/QuickCaptureWidget.tsx | 55 ++++ .../dashboard/RecentTasksWidget.tsx | 86 +++++ .../dashboard/UpcomingEventsWidget.tsx | 66 ++++ apps/web/src/components/hud/HUD.tsx | 182 +++++++++++ apps/web/src/components/hud/WidgetGrid.tsx | 117 +++++++ .../web/src/components/hud/WidgetRenderer.tsx | 74 +++++ apps/web/src/components/hud/WidgetWrapper.tsx | 109 +++++++ apps/web/src/components/hud/index.ts | 12 + apps/web/src/components/layout/Navigation.tsx | 3 +- .../components/widgets/AgentStatusWidget.tsx | 169 ++++++++++ .../src/components/widgets/CalendarWidget.tsx | 141 +++++++++ .../components/widgets/QuickCaptureWidget.tsx | 94 ++++++ .../src/components/widgets/TasksWidget.tsx | 134 ++++++++ apps/web/src/components/widgets/index.ts | 8 + .../src/components/widgets/widget-registry.ts | 22 ++ apps/web/src/lib/hooks/index.ts | 5 + apps/web/src/lib/hooks/useLayout.ts | 227 ++++++++++++++ packages/shared/src/types/index.ts | 3 + packages/shared/src/types/widget.types.ts | 81 +++++ packages/ui/src/components/Avatar.tsx | 67 ++++ packages/ui/src/components/Badge.tsx | 30 ++ packages/ui/src/components/Button.tsx | 3 +- packages/ui/src/components/Card.tsx | 57 ++++ packages/ui/src/components/Input.tsx | 58 ++++ packages/ui/src/components/Modal.tsx | 121 ++++++++ packages/ui/src/components/Select.tsx | 81 +++++ packages/ui/src/components/Textarea.tsx | 72 +++++ packages/ui/src/components/Toast.tsx | 188 +++++++++++ packages/ui/src/index.ts | 33 ++ pnpm-lock.yaml | 110 +++++++ 66 files changed, 4277 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/domains/domains.controller.ts create mode 100644 apps/api/src/domains/domains.module.ts create mode 100644 apps/api/src/domains/domains.service.ts create mode 100644 apps/api/src/domains/dto/create-domain.dto.ts create mode 100644 apps/api/src/domains/dto/index.ts create mode 100644 apps/api/src/domains/dto/query-domains.dto.ts create mode 100644 apps/api/src/domains/dto/update-domain.dto.ts create mode 100644 apps/api/src/ideas/dto/capture-idea.dto.ts create mode 100644 apps/api/src/ideas/dto/create-idea.dto.ts create mode 100644 apps/api/src/ideas/dto/index.ts create mode 100644 apps/api/src/ideas/dto/query-ideas.dto.ts create mode 100644 apps/api/src/ideas/dto/update-idea.dto.ts create mode 100644 apps/api/src/ideas/ideas.controller.ts create mode 100644 apps/api/src/ideas/ideas.module.ts create mode 100644 apps/api/src/ideas/ideas.service.ts create mode 100644 apps/api/src/layouts/dto/create-layout.dto.ts create mode 100644 apps/api/src/layouts/dto/index.ts create mode 100644 apps/api/src/layouts/dto/update-layout.dto.ts create mode 100644 apps/api/src/layouts/layouts.controller.ts create mode 100644 apps/api/src/layouts/layouts.module.ts create mode 100644 apps/api/src/layouts/layouts.service.ts create mode 100644 apps/api/src/layouts/types/widget.types.ts create mode 100644 apps/api/src/widgets/widgets.controller.ts create mode 100644 apps/api/src/widgets/widgets.module.ts create mode 100644 apps/api/src/widgets/widgets.service.ts create mode 100644 apps/web/src/app/(authenticated)/page.tsx create mode 100644 apps/web/src/components/dashboard/DomainOverviewWidget.tsx create mode 100644 apps/web/src/components/dashboard/QuickCaptureWidget.tsx create mode 100644 apps/web/src/components/dashboard/RecentTasksWidget.tsx create mode 100644 apps/web/src/components/dashboard/UpcomingEventsWidget.tsx create mode 100644 apps/web/src/components/hud/HUD.tsx create mode 100644 apps/web/src/components/hud/WidgetGrid.tsx create mode 100644 apps/web/src/components/hud/WidgetRenderer.tsx create mode 100644 apps/web/src/components/hud/WidgetWrapper.tsx create mode 100644 apps/web/src/components/hud/index.ts create mode 100644 apps/web/src/components/widgets/AgentStatusWidget.tsx create mode 100644 apps/web/src/components/widgets/CalendarWidget.tsx create mode 100644 apps/web/src/components/widgets/QuickCaptureWidget.tsx create mode 100644 apps/web/src/components/widgets/TasksWidget.tsx create mode 100644 apps/web/src/components/widgets/index.ts create mode 100644 apps/web/src/components/widgets/widget-registry.ts create mode 100644 apps/web/src/lib/hooks/index.ts create mode 100644 apps/web/src/lib/hooks/useLayout.ts create mode 100644 packages/shared/src/types/widget.types.ts create mode 100644 packages/ui/src/components/Avatar.tsx create mode 100644 packages/ui/src/components/Badge.tsx create mode 100644 packages/ui/src/components/Card.tsx create mode 100644 packages/ui/src/components/Input.tsx create mode 100644 packages/ui/src/components/Modal.tsx create mode 100644 packages/ui/src/components/Select.tsx create mode 100644 packages/ui/src/components/Textarea.tsx create mode 100644 packages/ui/src/components/Toast.tsx diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2b73af9..ba85432 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -534,6 +534,9 @@ model UserLayout { // Layout configuration (array of widget placements) layout Json @default("[]") + // Additional metadata for the layout + metadata Json @default("{}") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz diff --git a/apps/api/src/activity/activity.service.ts b/apps/api/src/activity/activity.service.ts index 16a6eca..2c381e9 100644 --- a/apps/api/src/activity/activity.service.ts +++ b/apps/api/src/activity/activity.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; -import { ActivityAction, EntityType } from "@prisma/client"; +import { ActivityAction, EntityType, Prisma } from "@prisma/client"; import type { CreateActivityLogInput, PaginatedActivityLogs, @@ -23,7 +23,7 @@ export class ActivityService { async logActivity(input: CreateActivityLogInput) { try { return await this.prisma.activityLog.create({ - data: input, + data: input as unknown as Prisma.ActivityLogCreateInput, }); } catch (error) { this.logger.error("Failed to log activity", error); @@ -167,7 +167,7 @@ export class ActivityService { workspaceId: string, userId: string, taskId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -186,7 +186,7 @@ export class ActivityService { workspaceId: string, userId: string, taskId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -205,7 +205,7 @@ export class ActivityService { workspaceId: string, userId: string, taskId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -224,7 +224,7 @@ export class ActivityService { workspaceId: string, userId: string, taskId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -262,7 +262,7 @@ export class ActivityService { workspaceId: string, userId: string, eventId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -281,7 +281,7 @@ export class ActivityService { workspaceId: string, userId: string, eventId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -300,7 +300,7 @@ export class ActivityService { workspaceId: string, userId: string, eventId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -319,7 +319,7 @@ export class ActivityService { workspaceId: string, userId: string, projectId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -338,7 +338,7 @@ export class ActivityService { workspaceId: string, userId: string, projectId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -357,7 +357,7 @@ export class ActivityService { workspaceId: string, userId: string, projectId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -375,7 +375,7 @@ export class ActivityService { async logWorkspaceCreated( workspaceId: string, userId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -393,7 +393,7 @@ export class ActivityService { async logWorkspaceUpdated( workspaceId: string, userId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -448,7 +448,7 @@ export class ActivityService { async logUserUpdated( workspaceId: string, userId: string, - details?: Record + details?: Prisma.JsonValue ) { return this.logActivity({ workspaceId, @@ -459,4 +459,118 @@ export class ActivityService { ...(details && { details }), }); } + + /** + * Log domain creation + */ + async logDomainCreated( + workspaceId: string, + userId: string, + domainId: string, + details?: Prisma.JsonValue + ) { + return this.logActivity({ + workspaceId, + userId, + action: ActivityAction.CREATED, + entityType: EntityType.DOMAIN, + entityId: domainId, + ...(details && { details }), + }); + } + + /** + * Log domain update + */ + async logDomainUpdated( + workspaceId: string, + userId: string, + domainId: string, + details?: Prisma.JsonValue + ) { + return this.logActivity({ + workspaceId, + userId, + action: ActivityAction.UPDATED, + entityType: EntityType.DOMAIN, + entityId: domainId, + ...(details && { details }), + }); + } + + /** + * Log domain deletion + */ + async logDomainDeleted( + workspaceId: string, + userId: string, + domainId: string, + details?: Prisma.JsonValue + ) { + return this.logActivity({ + workspaceId, + userId, + action: ActivityAction.DELETED, + entityType: EntityType.DOMAIN, + entityId: domainId, + ...(details && { details }), + }); + } + + /** + * Log idea creation + */ + async logIdeaCreated( + workspaceId: string, + userId: string, + ideaId: string, + details?: Prisma.JsonValue + ) { + return this.logActivity({ + workspaceId, + userId, + action: ActivityAction.CREATED, + entityType: EntityType.IDEA, + entityId: ideaId, + ...(details && { details }), + }); + } + + /** + * Log idea update + */ + async logIdeaUpdated( + workspaceId: string, + userId: string, + ideaId: string, + details?: Prisma.JsonValue + ) { + return this.logActivity({ + workspaceId, + userId, + action: ActivityAction.UPDATED, + entityType: EntityType.IDEA, + entityId: ideaId, + ...(details && { details }), + }); + } + + /** + * Log idea deletion + */ + async logIdeaDeleted( + workspaceId: string, + userId: string, + ideaId: string, + details?: Prisma.JsonValue + ) { + return this.logActivity({ + workspaceId, + userId, + action: ActivityAction.DELETED, + entityType: EntityType.IDEA, + entityId: ideaId, + ...(details && { details }), + }); + } } diff --git a/apps/api/src/activity/interfaces/activity.interface.ts b/apps/api/src/activity/interfaces/activity.interface.ts index 051b084..cd6b1c3 100644 --- a/apps/api/src/activity/interfaces/activity.interface.ts +++ b/apps/api/src/activity/interfaces/activity.interface.ts @@ -9,7 +9,7 @@ export interface CreateActivityLogInput { action: ActivityAction; entityType: EntityType; entityId: string; - details?: Record; + details?: Prisma.JsonValue; ipAddress?: string; userAgent?: string; } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2e43d6f..fa4ea35 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -8,6 +8,10 @@ import { ActivityModule } from "./activity/activity.module"; import { TasksModule } from "./tasks/tasks.module"; import { EventsModule } from "./events/events.module"; import { ProjectsModule } from "./projects/projects.module"; +import { DomainsModule } from "./domains/domains.module"; +import { IdeasModule } from "./ideas/ideas.module"; +import { WidgetsModule } from "./widgets/widgets.module"; +import { LayoutsModule } from "./layouts/layouts.module"; @Module({ imports: [ @@ -18,6 +22,10 @@ import { ProjectsModule } from "./projects/projects.module"; TasksModule, EventsModule, ProjectsModule, + DomainsModule, + IdeasModule, + WidgetsModule, + LayoutsModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/domains/domains.controller.ts b/apps/api/src/domains/domains.controller.ts new file mode 100644 index 0000000..f48f0e0 --- /dev/null +++ b/apps/api/src/domains/domains.controller.ts @@ -0,0 +1,104 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + Request, + UnauthorizedException, +} from "@nestjs/common"; +import { DomainsService } from "./domains.service"; +import { CreateDomainDto, UpdateDomainDto, QueryDomainsDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; + +/** + * Controller for domain endpoints + * All endpoints require authentication + */ +@Controller("domains") +@UseGuards(AuthGuard) +export class DomainsController { + constructor(private readonly domainsService: DomainsService) {} + + /** + * POST /api/domains + * Create a new domain + */ + @Post() + async create(@Body() createDomainDto: CreateDomainDto, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.domainsService.create(workspaceId, userId, createDomainDto); + } + + /** + * GET /api/domains + * Get paginated domains with optional filters + */ + @Get() + async findAll(@Query() query: QueryDomainsDto, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + return this.domainsService.findAll({ ...query, workspaceId }); + } + + /** + * GET /api/domains/:id + * Get a single domain by ID + */ + @Get(":id") + async findOne(@Param("id") id: string, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + return this.domainsService.findOne(id, workspaceId); + } + + /** + * PATCH /api/domains/:id + * Update a domain + */ + @Patch(":id") + async update( + @Param("id") id: string, + @Body() updateDomainDto: UpdateDomainDto, + @Request() req: any + ) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.domainsService.update(id, workspaceId, userId, updateDomainDto); + } + + /** + * DELETE /api/domains/:id + * Delete a domain + */ + @Delete(":id") + async remove(@Param("id") id: string, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.domainsService.remove(id, workspaceId, userId); + } +} diff --git a/apps/api/src/domains/domains.module.ts b/apps/api/src/domains/domains.module.ts new file mode 100644 index 0000000..09472e0 --- /dev/null +++ b/apps/api/src/domains/domains.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { DomainsController } from "./domains.controller"; +import { DomainsService } from "./domains.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { ActivityModule } from "../activity/activity.module"; +import { AuthModule } from "../auth/auth.module"; + +@Module({ + imports: [PrismaModule, ActivityModule, AuthModule], + controllers: [DomainsController], + providers: [DomainsService], + exports: [DomainsService], +}) +export class DomainsModule {} diff --git a/apps/api/src/domains/domains.service.ts b/apps/api/src/domains/domains.service.ts new file mode 100644 index 0000000..ea73467 --- /dev/null +++ b/apps/api/src/domains/domains.service.ts @@ -0,0 +1,199 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { ActivityService } from "../activity/activity.service"; +import type { CreateDomainDto, UpdateDomainDto, QueryDomainsDto } from "./dto"; + +/** + * Service for managing domains + */ +@Injectable() +export class DomainsService { + constructor( + private readonly prisma: PrismaService, + private readonly activityService: ActivityService + ) {} + + /** + * Create a new domain + */ + async create( + workspaceId: string, + userId: string, + createDomainDto: CreateDomainDto + ) { + const domain = await this.prisma.domain.create({ + data: { + ...createDomainDto, + workspaceId, + metadata: (createDomainDto.metadata || {}) as unknown as Prisma.InputJsonValue, + sortOrder: 0, // Default to 0, consistent with other services + }, + include: { + _count: { + select: { tasks: true, events: true, projects: true, ideas: true }, + }, + }, + }); + + // Log activity + await this.activityService.logDomainCreated( + workspaceId, + userId, + domain.id, + { + name: domain.name, + } + ); + + return domain; + } + + /** + * Get paginated domains with filters + */ + async findAll(query: QueryDomainsDto) { + const page = query.page || 1; + const limit = query.limit || 50; + const skip = (page - 1) * limit; + + // Build where clause + const where: any = { + workspaceId: query.workspaceId, + }; + + // Add search filter if provided + if (query.search) { + where.OR = [ + { name: { contains: query.search, mode: "insensitive" } }, + { description: { contains: query.search, mode: "insensitive" } }, + ]; + } + + // Execute queries in parallel + const [data, total] = await Promise.all([ + this.prisma.domain.findMany({ + where, + include: { + _count: { + select: { tasks: true, events: true, projects: true, ideas: true }, + }, + }, + orderBy: { + sortOrder: "asc", + }, + skip, + take: limit, + }), + this.prisma.domain.count({ where }), + ]); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Get a single domain by ID + */ + async findOne(id: string, workspaceId: string) { + const domain = await this.prisma.domain.findUnique({ + where: { + id, + workspaceId, + }, + include: { + _count: { + select: { tasks: true, events: true, projects: true, ideas: true }, + }, + }, + }); + + if (!domain) { + throw new NotFoundException(`Domain with ID ${id} not found`); + } + + return domain; + } + + /** + * Update a domain + */ + async update( + id: string, + workspaceId: string, + userId: string, + updateDomainDto: UpdateDomainDto + ) { + // Verify domain exists + const existingDomain = await this.prisma.domain.findUnique({ + where: { id, workspaceId }, + }); + + if (!existingDomain) { + throw new NotFoundException(`Domain with ID ${id} not found`); + } + + const domain = await this.prisma.domain.update({ + where: { + id, + workspaceId, + }, + data: updateDomainDto as any, + include: { + _count: { + select: { tasks: true, events: true, projects: true, ideas: true }, + }, + }, + }); + + // Log activity + await this.activityService.logDomainUpdated( + workspaceId, + userId, + id, + { + changes: updateDomainDto as Prisma.JsonValue, + } + ); + + return domain; + } + + /** + * Delete a domain + */ + async remove(id: string, workspaceId: string, userId: string) { + // Verify domain exists + const domain = await this.prisma.domain.findUnique({ + where: { id, workspaceId }, + }); + + if (!domain) { + throw new NotFoundException(`Domain with ID ${id} not found`); + } + + await this.prisma.domain.delete({ + where: { + id, + workspaceId, + }, + }); + + // Log activity + await this.activityService.logDomainDeleted( + workspaceId, + userId, + id, + { + name: domain.name, + } + ); + } +} diff --git a/apps/api/src/domains/dto/create-domain.dto.ts b/apps/api/src/domains/dto/create-domain.dto.ts new file mode 100644 index 0000000..9e1fbcf --- /dev/null +++ b/apps/api/src/domains/dto/create-domain.dto.ts @@ -0,0 +1,47 @@ +import { + IsString, + IsOptional, + MinLength, + MaxLength, + Matches, + IsObject, +} from "class-validator"; + +/** + * DTO for creating a new domain + */ +export class CreateDomainDto { + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name!: string; + + @IsString({ message: "slug must be a string" }) + @MinLength(1, { message: "slug must not be empty" }) + @MaxLength(100, { message: "slug must not exceed 100 characters" }) + @Matches(/^[a-z0-9-]+$/, { + message: "slug must contain only lowercase letters, numbers, and hyphens", + }) + slug!: string; + + @IsOptional() + @IsString({ message: "description must be a string" }) + @MaxLength(10000, { message: "description must not exceed 10000 characters" }) + description?: string; + + @IsOptional() + @IsString({ message: "color must be a string" }) + @Matches(/^#[0-9A-F]{6}$/i, { + message: "color must be a valid hex color code (e.g., #FF5733)", + }) + color?: string; + + @IsOptional() + @IsString({ message: "icon must be a string" }) + @MaxLength(50, { message: "icon must not exceed 50 characters" }) + icon?: string; + + @IsOptional() + @IsObject({ message: "metadata must be an object" }) + metadata?: Record; +} diff --git a/apps/api/src/domains/dto/index.ts b/apps/api/src/domains/dto/index.ts new file mode 100644 index 0000000..cada579 --- /dev/null +++ b/apps/api/src/domains/dto/index.ts @@ -0,0 +1,3 @@ +export { CreateDomainDto } from "./create-domain.dto"; +export { UpdateDomainDto } from "./update-domain.dto"; +export { QueryDomainsDto } from "./query-domains.dto"; diff --git a/apps/api/src/domains/dto/query-domains.dto.ts b/apps/api/src/domains/dto/query-domains.dto.ts new file mode 100644 index 0000000..1270973 --- /dev/null +++ b/apps/api/src/domains/dto/query-domains.dto.ts @@ -0,0 +1,34 @@ +import { + IsUUID, + IsOptional, + IsInt, + Min, + Max, + IsString, +} from "class-validator"; +import { Type } from "class-transformer"; + +/** + * DTO for querying domains with filters and pagination + */ +export class QueryDomainsDto { + @IsUUID("4", { message: "workspaceId must be a valid UUID" }) + workspaceId!: string; + + @IsOptional() + @IsString({ message: "search must be a string" }) + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} diff --git a/apps/api/src/domains/dto/update-domain.dto.ts b/apps/api/src/domains/dto/update-domain.dto.ts new file mode 100644 index 0000000..ccf417c --- /dev/null +++ b/apps/api/src/domains/dto/update-domain.dto.ts @@ -0,0 +1,50 @@ +import { + IsString, + IsOptional, + MinLength, + MaxLength, + Matches, + IsObject, +} from "class-validator"; + +/** + * DTO for updating an existing domain + * All fields are optional to support partial updates + */ +export class UpdateDomainDto { + @IsOptional() + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name?: string; + + @IsOptional() + @IsString({ message: "slug must be a string" }) + @MinLength(1, { message: "slug must not be empty" }) + @MaxLength(100, { message: "slug must not exceed 100 characters" }) + @Matches(/^[a-z0-9-]+$/, { + message: "slug must contain only lowercase letters, numbers, and hyphens", + }) + slug?: string; + + @IsOptional() + @IsString({ message: "description must be a string" }) + @MaxLength(10000, { message: "description must not exceed 10000 characters" }) + description?: string | null; + + @IsOptional() + @IsString({ message: "color must be a string" }) + @Matches(/^#[0-9A-F]{6}$/i, { + message: "color must be a valid hex color code (e.g., #FF5733)", + }) + color?: string | null; + + @IsOptional() + @IsString({ message: "icon must be a string" }) + @MaxLength(50, { message: "icon must not exceed 50 characters" }) + icon?: string | null; + + @IsOptional() + @IsObject({ message: "metadata must be an object" }) + metadata?: Record; +} diff --git a/apps/api/src/events/dto/update-event.dto.ts b/apps/api/src/events/dto/update-event.dto.ts index 2094102..c5a6b60 100644 --- a/apps/api/src/events/dto/update-event.dto.ts +++ b/apps/api/src/events/dto/update-event.dto.ts @@ -23,7 +23,7 @@ export class UpdateEventDto { @IsOptional() @IsString({ message: "description must be a string" }) @MaxLength(10000, { message: "description must not exceed 10000 characters" }) - description?: string | null; + description?: string; @IsOptional() @IsDateString({}, { message: "startTime must be a valid ISO 8601 date string" }) @@ -31,7 +31,7 @@ export class UpdateEventDto { @IsOptional() @IsDateString({}, { message: "endTime must be a valid ISO 8601 date string" }) - endTime?: Date | null; + endTime?: Date; @IsOptional() @IsBoolean({ message: "allDay must be a boolean" }) @@ -40,15 +40,15 @@ export class UpdateEventDto { @IsOptional() @IsString({ message: "location must be a string" }) @MaxLength(500, { message: "location must not exceed 500 characters" }) - location?: string | null; + location?: string; @IsOptional() @IsObject({ message: "recurrence must be an object" }) - recurrence?: Record | null; + recurrence?: Record; @IsOptional() @IsUUID("4", { message: "projectId must be a valid UUID" }) - projectId?: string | null; + projectId?: string; @IsOptional() @IsObject({ message: "metadata must be an object" }) diff --git a/apps/api/src/events/events.service.ts b/apps/api/src/events/events.service.ts index 51b8214..8bfc98b 100644 --- a/apps/api/src/events/events.service.ts +++ b/apps/api/src/events/events.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { ActivityService } from "../activity/activity.service"; import type { CreateEventDto, UpdateEventDto, QueryEventsDto } from "./dto"; @@ -157,7 +158,7 @@ export class EventsService { id, workspaceId, }, - data: updateEventDto, + data: updateEventDto as any, include: { creator: { select: { id: true, name: true, email: true }, @@ -170,7 +171,7 @@ export class EventsService { // Log activity await this.activityService.logEventUpdated(workspaceId, userId, id, { - changes: updateEventDto, + changes: updateEventDto as Prisma.JsonValue, }); return event; diff --git a/apps/api/src/ideas/dto/capture-idea.dto.ts b/apps/api/src/ideas/dto/capture-idea.dto.ts new file mode 100644 index 0000000..0f93dbc --- /dev/null +++ b/apps/api/src/ideas/dto/capture-idea.dto.ts @@ -0,0 +1,22 @@ +import { + IsString, + IsOptional, + MinLength, + MaxLength, +} from "class-validator"; + +/** + * DTO for quick capturing ideas with minimal fields + * Intended for rapid idea capture without complex categorization + */ +export class CaptureIdeaDto { + @IsString({ message: "content must be a string" }) + @MinLength(1, { message: "content must not be empty" }) + @MaxLength(50000, { message: "content must not exceed 50000 characters" }) + content!: string; + + @IsOptional() + @IsString({ message: "title must be a string" }) + @MaxLength(500, { message: "title must not exceed 500 characters" }) + title?: string; +} diff --git a/apps/api/src/ideas/dto/create-idea.dto.ts b/apps/api/src/ideas/dto/create-idea.dto.ts new file mode 100644 index 0000000..d017332 --- /dev/null +++ b/apps/api/src/ideas/dto/create-idea.dto.ts @@ -0,0 +1,56 @@ +import { IdeaStatus, TaskPriority } from "@prisma/client"; +import { + IsString, + IsOptional, + IsEnum, + IsArray, + IsUUID, + IsObject, + MinLength, + MaxLength, +} from "class-validator"; + +/** + * DTO for creating a new idea + */ +export class CreateIdeaDto { + @IsOptional() + @IsString({ message: "title must be a string" }) + @MaxLength(500, { message: "title must not exceed 500 characters" }) + title?: string; + + @IsString({ message: "content must be a string" }) + @MinLength(1, { message: "content must not be empty" }) + @MaxLength(50000, { message: "content must not exceed 50000 characters" }) + content!: string; + + @IsOptional() + @IsUUID("4", { message: "domainId must be a valid UUID" }) + domainId?: string; + + @IsOptional() + @IsUUID("4", { message: "projectId must be a valid UUID" }) + projectId?: string; + + @IsOptional() + @IsEnum(IdeaStatus, { message: "status must be a valid IdeaStatus" }) + status?: IdeaStatus; + + @IsOptional() + @IsEnum(TaskPriority, { message: "priority must be a valid TaskPriority" }) + priority?: TaskPriority; + + @IsOptional() + @IsString({ message: "category must be a string" }) + @MaxLength(100, { message: "category must not exceed 100 characters" }) + category?: string; + + @IsOptional() + @IsArray({ message: "tags must be an array" }) + @IsString({ each: true, message: "each tag must be a string" }) + tags?: string[]; + + @IsOptional() + @IsObject({ message: "metadata must be an object" }) + metadata?: Record; +} diff --git a/apps/api/src/ideas/dto/index.ts b/apps/api/src/ideas/dto/index.ts new file mode 100644 index 0000000..75fbef2 --- /dev/null +++ b/apps/api/src/ideas/dto/index.ts @@ -0,0 +1,4 @@ +export { CreateIdeaDto } from "./create-idea.dto"; +export { CaptureIdeaDto } from "./capture-idea.dto"; +export { UpdateIdeaDto } from "./update-idea.dto"; +export { QueryIdeasDto } from "./query-ideas.dto"; diff --git a/apps/api/src/ideas/dto/query-ideas.dto.ts b/apps/api/src/ideas/dto/query-ideas.dto.ts new file mode 100644 index 0000000..7d2f0bb --- /dev/null +++ b/apps/api/src/ideas/dto/query-ideas.dto.ts @@ -0,0 +1,52 @@ +import { IdeaStatus } from "@prisma/client"; +import { + IsUUID, + IsOptional, + IsEnum, + IsInt, + Min, + Max, + IsString, +} from "class-validator"; +import { Type } from "class-transformer"; + +/** + * DTO for querying ideas with filters and pagination + */ +export class QueryIdeasDto { + @IsUUID("4", { message: "workspaceId must be a valid UUID" }) + workspaceId!: string; + + @IsOptional() + @IsEnum(IdeaStatus, { message: "status must be a valid IdeaStatus" }) + status?: IdeaStatus; + + @IsOptional() + @IsUUID("4", { message: "domainId must be a valid UUID" }) + domainId?: string; + + @IsOptional() + @IsUUID("4", { message: "projectId must be a valid UUID" }) + projectId?: string; + + @IsOptional() + @IsString({ message: "category must be a string" }) + category?: string; + + @IsOptional() + @IsString({ message: "search must be a string" }) + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} diff --git a/apps/api/src/ideas/dto/update-idea.dto.ts b/apps/api/src/ideas/dto/update-idea.dto.ts new file mode 100644 index 0000000..c3fb9f7 --- /dev/null +++ b/apps/api/src/ideas/dto/update-idea.dto.ts @@ -0,0 +1,58 @@ +import { IdeaStatus, TaskPriority } from "@prisma/client"; +import { + IsString, + IsOptional, + IsEnum, + IsArray, + IsUUID, + IsObject, + MinLength, + MaxLength, +} from "class-validator"; + +/** + * DTO for updating an existing idea + * All fields are optional to support partial updates + */ +export class UpdateIdeaDto { + @IsOptional() + @IsString({ message: "title must be a string" }) + @MaxLength(500, { message: "title must not exceed 500 characters" }) + title?: string; + + @IsOptional() + @IsString({ message: "content must be a string" }) + @MinLength(1, { message: "content must not be empty" }) + @MaxLength(50000, { message: "content must not exceed 50000 characters" }) + content?: string; + + @IsOptional() + @IsUUID("4", { message: "domainId must be a valid UUID" }) + domainId?: string; + + @IsOptional() + @IsUUID("4", { message: "projectId must be a valid UUID" }) + projectId?: string; + + @IsOptional() + @IsEnum(IdeaStatus, { message: "status must be a valid IdeaStatus" }) + status?: IdeaStatus; + + @IsOptional() + @IsEnum(TaskPriority, { message: "priority must be a valid TaskPriority" }) + priority?: TaskPriority; + + @IsOptional() + @IsString({ message: "category must be a string" }) + @MaxLength(100, { message: "category must not exceed 100 characters" }) + category?: string; + + @IsOptional() + @IsArray({ message: "tags must be an array" }) + @IsString({ each: true, message: "each tag must be a string" }) + tags?: string[]; + + @IsOptional() + @IsObject({ message: "metadata must be an object" }) + metadata?: Record; +} diff --git a/apps/api/src/ideas/ideas.controller.ts b/apps/api/src/ideas/ideas.controller.ts new file mode 100644 index 0000000..a8975e6 --- /dev/null +++ b/apps/api/src/ideas/ideas.controller.ts @@ -0,0 +1,130 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + Request, + UnauthorizedException, +} from "@nestjs/common"; +import { IdeasService } from "./ideas.service"; +import { + CreateIdeaDto, + CaptureIdeaDto, + UpdateIdeaDto, + QueryIdeasDto, +} from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; + +/** + * Controller for idea endpoints + * All endpoints require authentication + */ +@Controller("ideas") +@UseGuards(AuthGuard) +export class IdeasController { + constructor(private readonly ideasService: IdeasService) {} + + /** + * POST /api/ideas/capture + * Quick capture endpoint for rapid idea capture + * Requires minimal fields: content only (title optional) + */ + @Post("capture") + async capture( + @Body() captureIdeaDto: CaptureIdeaDto, + @Request() req: any + ) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.ideasService.capture(workspaceId, userId, captureIdeaDto); + } + + /** + * POST /api/ideas + * Create a new idea with full categorization options + */ + @Post() + async create(@Body() createIdeaDto: CreateIdeaDto, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.ideasService.create(workspaceId, userId, createIdeaDto); + } + + /** + * GET /api/ideas + * Get paginated ideas with optional filters + * Supports status, domain, project, category, and search filters + */ + @Get() + async findAll(@Query() query: QueryIdeasDto, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + return this.ideasService.findAll({ ...query, workspaceId }); + } + + /** + * GET /api/ideas/:id + * Get a single idea by ID + */ + @Get(":id") + async findOne(@Param("id") id: string, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + if (!workspaceId) { + throw new UnauthorizedException("Authentication required"); + } + return this.ideasService.findOne(id, workspaceId); + } + + /** + * PATCH /api/ideas/:id + * Update an idea + */ + @Patch(":id") + async update( + @Param("id") id: string, + @Body() updateIdeaDto: UpdateIdeaDto, + @Request() req: any + ) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.ideasService.update(id, workspaceId, userId, updateIdeaDto); + } + + /** + * DELETE /api/ideas/:id + * Delete an idea + */ + @Delete(":id") + async remove(@Param("id") id: string, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.ideasService.remove(id, workspaceId, userId); + } +} diff --git a/apps/api/src/ideas/ideas.module.ts b/apps/api/src/ideas/ideas.module.ts new file mode 100644 index 0000000..e0b9c2c --- /dev/null +++ b/apps/api/src/ideas/ideas.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { IdeasController } from "./ideas.controller"; +import { IdeasService } from "./ideas.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { ActivityModule } from "../activity/activity.module"; +import { AuthModule } from "../auth/auth.module"; + +@Module({ + imports: [PrismaModule, ActivityModule, AuthModule], + controllers: [IdeasController], + providers: [IdeasService], + exports: [IdeasService], +}) +export class IdeasModule {} diff --git a/apps/api/src/ideas/ideas.service.ts b/apps/api/src/ideas/ideas.service.ts new file mode 100644 index 0000000..872ae5c --- /dev/null +++ b/apps/api/src/ideas/ideas.service.ts @@ -0,0 +1,293 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { ActivityService } from "../activity/activity.service"; +import { IdeaStatus } from "@prisma/client"; +import type { + CreateIdeaDto, + CaptureIdeaDto, + UpdateIdeaDto, + QueryIdeasDto, +} from "./dto"; + +/** + * Service for managing ideas + */ +@Injectable() +export class IdeasService { + constructor( + private readonly prisma: PrismaService, + private readonly activityService: ActivityService + ) {} + + /** + * Create a new idea + */ + async create( + workspaceId: string, + userId: string, + createIdeaDto: CreateIdeaDto + ) { + const data: any = { + ...createIdeaDto, + workspaceId, + creatorId: userId, + status: createIdeaDto.status || IdeaStatus.CAPTURED, + priority: createIdeaDto.priority || "MEDIUM", + tags: createIdeaDto.tags || [], + metadata: createIdeaDto.metadata || {}, + }; + + const idea = await this.prisma.idea.create({ + data, + include: { + creator: { + select: { id: true, name: true, email: true }, + }, + domain: { + select: { id: true, name: true, color: true }, + }, + project: { + select: { id: true, name: true, color: true }, + }, + }, + }); + + // Log activity + await this.activityService.logIdeaCreated( + workspaceId, + userId, + idea.id, + { + title: idea.title || "Untitled", + } + ); + + return idea; + } + + /** + * Quick capture - create an idea with minimal fields + * Optimized for rapid idea capture from the front-end + */ + async capture( + workspaceId: string, + userId: string, + captureIdeaDto: CaptureIdeaDto + ) { + const data: any = { + workspaceId, + creatorId: userId, + content: captureIdeaDto.content, + title: captureIdeaDto.title, + status: IdeaStatus.CAPTURED, + priority: "MEDIUM", + tags: [], + metadata: {}, + }; + + const idea = await this.prisma.idea.create({ + data, + include: { + creator: { + select: { id: true, name: true, email: true }, + }, + }, + }); + + // Log activity + await this.activityService.logIdeaCreated( + workspaceId, + userId, + idea.id, + { + quickCapture: true, + title: idea.title || "Untitled", + } + ); + + return idea; + } + + /** + * Get paginated ideas with filters + */ + async findAll(query: QueryIdeasDto) { + const page = query.page || 1; + const limit = query.limit || 50; + const skip = (page - 1) * limit; + + // Build where clause + const where: any = { + workspaceId: query.workspaceId, + }; + + if (query.status) { + where.status = query.status; + } + + if (query.domainId) { + where.domainId = query.domainId; + } + + if (query.projectId) { + where.projectId = query.projectId; + } + + if (query.category) { + where.category = query.category; + } + + // Add search filter if provided + if (query.search) { + where.OR = [ + { title: { contains: query.search, mode: "insensitive" } }, + { content: { contains: query.search, mode: "insensitive" } }, + ]; + } + + // Execute queries in parallel + const [data, total] = await Promise.all([ + this.prisma.idea.findMany({ + where, + include: { + creator: { + select: { id: true, name: true, email: true }, + }, + domain: { + select: { id: true, name: true, color: true }, + }, + project: { + select: { id: true, name: true, color: true }, + }, + }, + orderBy: { + createdAt: "desc", + }, + skip, + take: limit, + }), + this.prisma.idea.count({ where }), + ]); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Get a single idea by ID + */ + async findOne(id: string, workspaceId: string) { + const idea = await this.prisma.idea.findUnique({ + where: { + id, + workspaceId, + }, + include: { + creator: { + select: { id: true, name: true, email: true }, + }, + domain: { + select: { id: true, name: true, color: true }, + }, + project: { + select: { id: true, name: true, color: true }, + }, + }, + }); + + if (!idea) { + throw new NotFoundException(`Idea with ID ${id} not found`); + } + + return idea; + } + + /** + * Update an idea + */ + async update( + id: string, + workspaceId: string, + userId: string, + updateIdeaDto: UpdateIdeaDto + ) { + // Verify idea exists + const existingIdea = await this.prisma.idea.findUnique({ + where: { id, workspaceId }, + }); + + if (!existingIdea) { + throw new NotFoundException(`Idea with ID ${id} not found`); + } + + const idea = await this.prisma.idea.update({ + where: { + id, + workspaceId, + }, + data: updateIdeaDto as any, + include: { + creator: { + select: { id: true, name: true, email: true }, + }, + domain: { + select: { id: true, name: true, color: true }, + }, + project: { + select: { id: true, name: true, color: true }, + }, + }, + }); + + // Log activity + await this.activityService.logIdeaUpdated( + workspaceId, + userId, + id, + { + changes: updateIdeaDto as Prisma.JsonValue, + } + ); + + return idea; + } + + /** + * Delete an idea + */ + async remove(id: string, workspaceId: string, userId: string) { + // Verify idea exists + const idea = await this.prisma.idea.findUnique({ + where: { id, workspaceId }, + }); + + if (!idea) { + throw new NotFoundException(`Idea with ID ${id} not found`); + } + + await this.prisma.idea.delete({ + where: { + id, + workspaceId, + }, + }); + + // Log activity + await this.activityService.logIdeaDeleted( + workspaceId, + userId, + id, + { + title: idea.title || "Untitled", + } + ); + } +} diff --git a/apps/api/src/layouts/dto/create-layout.dto.ts b/apps/api/src/layouts/dto/create-layout.dto.ts new file mode 100644 index 0000000..1f337a4 --- /dev/null +++ b/apps/api/src/layouts/dto/create-layout.dto.ts @@ -0,0 +1,31 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsArray, + MinLength, + MaxLength, + IsObject, +} from "class-validator"; +import { Layout } from "../types/widget.types"; + +/** + * DTO for creating a new user layout + */ +export class CreateLayoutDto { + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(100, { message: "name must not exceed 100 characters" }) + name!: string; + + @IsOptional() + @IsBoolean({ message: "isDefault must be a boolean" }) + isDefault?: boolean; + + @IsArray({ message: "layout must be an array" }) + layout!: Layout; + + @IsOptional() + @IsObject({ message: "metadata must be an object" }) + metadata?: Record; +} diff --git a/apps/api/src/layouts/dto/index.ts b/apps/api/src/layouts/dto/index.ts new file mode 100644 index 0000000..0120cad --- /dev/null +++ b/apps/api/src/layouts/dto/index.ts @@ -0,0 +1,2 @@ +export { CreateLayoutDto } from "./create-layout.dto"; +export { UpdateLayoutDto } from "./update-layout.dto"; diff --git a/apps/api/src/layouts/dto/update-layout.dto.ts b/apps/api/src/layouts/dto/update-layout.dto.ts new file mode 100644 index 0000000..f49e71a --- /dev/null +++ b/apps/api/src/layouts/dto/update-layout.dto.ts @@ -0,0 +1,34 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsArray, + MinLength, + MaxLength, + IsObject, +} from "class-validator"; +import { Layout } from "../types/widget.types"; + +/** + * DTO for updating an existing user layout + * All fields are optional to support partial updates + */ +export class UpdateLayoutDto { + @IsOptional() + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(100, { message: "name must not exceed 100 characters" }) + name?: string; + + @IsOptional() + @IsBoolean({ message: "isDefault must be a boolean" }) + isDefault?: boolean; + + @IsOptional() + @IsArray({ message: "layout must be an array" }) + layout?: Layout; + + @IsOptional() + @IsObject({ message: "metadata must be an object" }) + metadata?: Record; +} diff --git a/apps/api/src/layouts/layouts.controller.ts b/apps/api/src/layouts/layouts.controller.ts new file mode 100644 index 0000000..095805b --- /dev/null +++ b/apps/api/src/layouts/layouts.controller.ts @@ -0,0 +1,128 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, + Request, + UnauthorizedException, +} from "@nestjs/common"; +import { LayoutsService } from "./layouts.service"; +import { CreateLayoutDto, UpdateLayoutDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; + +/** + * Controller for user layout endpoints + * All endpoints require authentication + */ +@Controller("layouts") +@UseGuards(AuthGuard) +export class LayoutsController { + constructor(private readonly layoutsService: LayoutsService) {} + + /** + * GET /api/layouts + * Get all layouts for the authenticated user + */ + @Get() + async findAll(@Request() req: any) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.layoutsService.findAll(workspaceId, userId); + } + + /** + * GET /api/layouts/:id + * Get a single layout by ID + */ + @Get(":id") + async findOne(@Param("id") id: string, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.layoutsService.findOne(id, workspaceId, userId); + } + + /** + * GET /api/layouts/default + * Get the default layout for the authenticated user + * Falls back to the most recently created layout if no default exists + */ + @Get("default") + async findDefault(@Request() req: any) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.layoutsService.findDefault(workspaceId, userId); + } + + /** + * POST /api/layouts + * Create a new layout + * If isDefault is true, any existing default layout will be unset + */ + @Post() + async create(@Body() createLayoutDto: CreateLayoutDto, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.layoutsService.create(workspaceId, userId, createLayoutDto); + } + + /** + * PATCH /api/layouts/:id + * Update a layout + * If isDefault is set to true, any existing default layout will be unset + */ + @Patch(":id") + async update( + @Param("id") id: string, + @Body() updateLayoutDto: UpdateLayoutDto, + @Request() req: any + ) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.layoutsService.update(id, workspaceId, userId, updateLayoutDto); + } + + /** + * DELETE /api/layouts/:id + * Delete a layout + */ + @Delete(":id") + async remove(@Param("id") id: string, @Request() req: any) { + const workspaceId = req.user?.workspaceId; + const userId = req.user?.id; + + if (!workspaceId || !userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.layoutsService.remove(id, workspaceId, userId); + } +} diff --git a/apps/api/src/layouts/layouts.module.ts b/apps/api/src/layouts/layouts.module.ts new file mode 100644 index 0000000..3941bdd --- /dev/null +++ b/apps/api/src/layouts/layouts.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { LayoutsController } from "./layouts.controller"; +import { LayoutsService } from "./layouts.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [LayoutsController], + providers: [LayoutsService], + exports: [LayoutsService], +}) +export class LayoutsModule {} diff --git a/apps/api/src/layouts/layouts.service.ts b/apps/api/src/layouts/layouts.service.ts new file mode 100644 index 0000000..ca89cf2 --- /dev/null +++ b/apps/api/src/layouts/layouts.service.ts @@ -0,0 +1,185 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import type { CreateLayoutDto, UpdateLayoutDto } from "./dto"; + +/** + * Service for managing user layouts + */ +@Injectable() +export class LayoutsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Get all layouts for a user + */ + async findAll(workspaceId: string, userId: string) { + return this.prisma.userLayout.findMany({ + where: { + workspaceId, + userId, + }, + orderBy: { + isDefault: "desc", + createdAt: "desc", + }, + }); + } + + /** + * Get the default layout for a user + */ + async findDefault(workspaceId: string, userId: string) { + const layout = await this.prisma.userLayout.findFirst({ + where: { + workspaceId, + userId, + isDefault: true, + }, + }); + + // If no default layout exists, return the most recently created one + if (!layout) { + const recentLayout = await this.prisma.userLayout.findFirst({ + where: { + workspaceId, + userId, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!recentLayout) { + throw new NotFoundException(`No layouts found for this user`); + } + + return recentLayout; + } + + return layout; + } + + /** + * Get a single layout by ID + */ + async findOne(id: string, workspaceId: string, userId: string) { + const layout = await this.prisma.userLayout.findUnique({ + where: { + id, + workspaceId, + userId, + }, + }); + + if (!layout) { + throw new NotFoundException(`Layout with ID ${id} not found`); + } + + return layout; + } + + /** + * Create a new layout + */ + async create( + workspaceId: string, + userId: string, + createLayoutDto: CreateLayoutDto + ) { + // Use transaction to ensure atomicity when setting default + return this.prisma.$transaction(async (tx) => { + // If setting as default, unset other defaults first + if (createLayoutDto.isDefault) { + await tx.userLayout.updateMany({ + where: { + workspaceId, + userId, + isDefault: true, + }, + data: { + isDefault: false, + }, + }); + } + + return tx.userLayout.create({ + data: { + ...createLayoutDto, + workspaceId, + userId, + isDefault: createLayoutDto.isDefault || false, + layout: (createLayoutDto.layout || []) as unknown as Prisma.JsonValue, + } as any, + }); + }); + } + + /** + * Update a layout + */ + async update( + id: string, + workspaceId: string, + userId: string, + updateLayoutDto: UpdateLayoutDto + ) { + // Use transaction to ensure atomicity when setting default + return this.prisma.$transaction(async (tx) => { + // Verify layout exists + const existingLayout = await tx.userLayout.findUnique({ + where: { id, workspaceId, userId }, + }); + + if (!existingLayout) { + throw new NotFoundException(`Layout with ID ${id} not found`); + } + + // If setting as default, unset other defaults first + if (updateLayoutDto.isDefault === true) { + await tx.userLayout.updateMany({ + where: { + workspaceId, + userId, + id: { not: id }, + isDefault: true, + }, + data: { + isDefault: false, + }, + }); + } + + return tx.userLayout.update({ + where: { + id, + workspaceId, + userId, + }, + data: updateLayoutDto as any, + }); + }); + } + + /** + * Delete a layout + */ + async remove(id: string, workspaceId: string, userId: string) { + // Verify layout exists + const layout = await this.prisma.userLayout.findUnique({ + where: { id, workspaceId, userId }, + }); + + if (!layout) { + throw new NotFoundException(`Layout with ID ${id} not found`); + } + + await this.prisma.userLayout.delete({ + where: { + id, + workspaceId, + userId, + }, + }); + } +} diff --git a/apps/api/src/layouts/types/widget.types.ts b/apps/api/src/layouts/types/widget.types.ts new file mode 100644 index 0000000..948979b --- /dev/null +++ b/apps/api/src/layouts/types/widget.types.ts @@ -0,0 +1,35 @@ +/** + * Widget configuration types for user layouts + */ + +/** + * Base widget interface + */ +export interface Widget { + id: string; + type: string; + x: number; + y: number; + w: number; + h: number; + minW?: number; + minH?: number; + maxW?: number; + maxH?: number; + isDraggable?: boolean; + isResizable?: boolean; + isBounded?: boolean; + static?: boolean; +} + +/** + * Widget with configuration + */ +export interface ConfiguredWidget extends Widget { + config?: Record; +} + +/** + * Layout configuration type + */ +export type Layout = ConfiguredWidget[]; diff --git a/apps/api/src/projects/projects.service.ts b/apps/api/src/projects/projects.service.ts index 5d9f82b..d1c3c82 100644 --- a/apps/api/src/projects/projects.service.ts +++ b/apps/api/src/projects/projects.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { ActivityService } from "../activity/activity.service"; import { ProjectStatus } from "@prisma/client"; @@ -177,7 +178,7 @@ export class ProjectsService { id, workspaceId, }, - data: updateProjectDto, + data: updateProjectDto as any, include: { creator: { select: { id: true, name: true, email: true }, @@ -190,7 +191,7 @@ export class ProjectsService { // Log activity await this.activityService.logProjectUpdated(workspaceId, userId, id, { - changes: updateProjectDto, + changes: updateProjectDto as Prisma.JsonValue, }); return project; diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 5617304..e06058c 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { ActivityService } from "../activity/activity.service"; import { TaskStatus, TaskPriority } from "@prisma/client"; @@ -226,7 +227,7 @@ export class TasksService { // Log activities await this.activityService.logTaskUpdated(workspaceId, userId, id, { - changes: updateTaskDto, + changes: updateTaskDto as Prisma.JsonValue, }); // Log completion if status changed to COMPLETED diff --git a/apps/api/src/widgets/widgets.controller.ts b/apps/api/src/widgets/widgets.controller.ts new file mode 100644 index 0000000..2c4a3fc --- /dev/null +++ b/apps/api/src/widgets/widgets.controller.ts @@ -0,0 +1,39 @@ +import { + Controller, + Get, + Param, + UseGuards, +} from "@nestjs/common"; +import { WidgetsService } from "./widgets.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; + +/** + * Controller for widget definition endpoints + * All endpoints require authentication + * Provides read-only access to available widget definitions + */ +@Controller("widgets") +@UseGuards(AuthGuard) +export class WidgetsController { + constructor(private readonly widgetsService: WidgetsService) {} + + /** + * GET /api/widgets + * List all available widget definitions + * Returns only active widgets + */ + @Get() + async findAll() { + return this.widgetsService.findAll(); + } + + /** + * GET /api/widgets/:name + * Get a widget definition by name + * Useful for fetching widget configuration schemas + */ + @Get(":name") + async findByName(@Param("name") name: string) { + return this.widgetsService.findByName(name); + } +} diff --git a/apps/api/src/widgets/widgets.module.ts b/apps/api/src/widgets/widgets.module.ts new file mode 100644 index 0000000..64b20cb --- /dev/null +++ b/apps/api/src/widgets/widgets.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { WidgetsController } from "./widgets.controller"; +import { WidgetsService } from "./widgets.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [WidgetsController], + providers: [WidgetsService], + exports: [WidgetsService], +}) +export class WidgetsModule {} diff --git a/apps/api/src/widgets/widgets.service.ts b/apps/api/src/widgets/widgets.service.ts new file mode 100644 index 0000000..1bd7096 --- /dev/null +++ b/apps/api/src/widgets/widgets.service.ts @@ -0,0 +1,59 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; + +/** + * Service for managing widget definitions + * Provides read-only access to available widget definitions + */ +@Injectable() +export class WidgetsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Get all active widget definitions + */ + async findAll() { + return this.prisma.widgetDefinition.findMany({ + where: { + isActive: true, + }, + orderBy: { + name: "asc", + }, + }); + } + + /** + * Get a widget definition by name + */ + async findByName(name: string) { + const widget = await this.prisma.widgetDefinition.findUnique({ + where: { + name, + }, + }); + + if (!widget) { + throw new NotFoundException(`Widget definition with name '${name}' not found`); + } + + return widget; + } + + /** + * Get a widget definition by ID + */ + async findOne(id: string) { + const widget = await this.prisma.widgetDefinition.findUnique({ + where: { + id, + }, + }); + + if (!widget) { + throw new NotFoundException(`Widget definition with ID ${id} not found`); + } + + return widget; + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 18b0ec2..8678c7c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,9 +19,11 @@ "@mosaic/ui": "workspace:*", "@tanstack/react-query": "^5.90.20", "date-fns": "^4.1.0", + "lucide-react": "^0.563.0", "next": "^16.1.6", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-grid-layout": "^2.2.2" }, "devDependencies": { "@mosaic/config": "workspace:*", @@ -31,6 +33,7 @@ "@types/node": "^22.13.4", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", + "@types/react-grid-layout": "^2.1.0", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^3.2.4", "jsdom": "^26.0.0", diff --git a/apps/web/src/app/(authenticated)/page.tsx b/apps/web/src/app/(authenticated)/page.tsx new file mode 100644 index 0000000..6b2fc2d --- /dev/null +++ b/apps/web/src/app/(authenticated)/page.tsx @@ -0,0 +1,48 @@ +import { RecentTasksWidget } from "@/components/dashboard/RecentTasksWidget"; +import { UpcomingEventsWidget } from "@/components/dashboard/UpcomingEventsWidget"; +import { QuickCaptureWidget } from "@/components/dashboard/QuickCaptureWidget"; +import { DomainOverviewWidget } from "@/components/dashboard/DomainOverviewWidget"; +import { mockTasks } from "@/lib/api/tasks"; +import { mockEvents } from "@/lib/api/events"; + +export default function DashboardPage() { + // TODO: Replace with real API call when backend is ready + // const { data: tasks, isLoading: tasksLoading } = useQuery({ + // queryKey: ["tasks"], + // queryFn: fetchTasks, + // }); + // const { data: events, isLoading: eventsLoading } = useQuery({ + // queryKey: ["events"], + // queryFn: fetchEvents, + // }); + + const tasks = mockTasks; + const events = mockEvents; + const tasksLoading = false; + const eventsLoading = false; + + return ( +
+
+

Dashboard

+

+ Welcome back! Here's your overview +

+
+ +
+ {/* Top row: Domain Overview and Quick Capture */} +
+ +
+ + + + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/dashboard/DomainOverviewWidget.tsx b/apps/web/src/components/dashboard/DomainOverviewWidget.tsx new file mode 100644 index 0000000..6dd5e4b --- /dev/null +++ b/apps/web/src/components/dashboard/DomainOverviewWidget.tsx @@ -0,0 +1,46 @@ +import type { Task } from "@mosaic/shared"; +import { TaskStatus, TaskPriority } from "@mosaic/shared"; + +interface DomainOverviewWidgetProps { + tasks: Task[]; + isLoading: boolean; +} + +export function DomainOverviewWidget({ tasks, isLoading }: DomainOverviewWidgetProps) { + if (isLoading) { + return ( +
+
+
+ Loading overview... +
+
+ ); + } + + const stats = { + total: tasks.length, + inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length, + completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length, + highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length, + }; + + const StatCard = ({ label, value, color }: { label: string; value: number; color: string }) => ( +
+
{value}
+
{label}
+
+ ); + + return ( +
+

Domain Overview

+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/dashboard/QuickCaptureWidget.tsx b/apps/web/src/components/dashboard/QuickCaptureWidget.tsx new file mode 100644 index 0000000..3a6515a --- /dev/null +++ b/apps/web/src/components/dashboard/QuickCaptureWidget.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@mosaic/ui"; +import { useRouter } from "next/navigation"; + +export function QuickCaptureWidget() { + const [idea, setIdea] = useState(""); + const router = useRouter(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!idea.trim()) return; + + // TODO: Implement quick capture API call + // For now, just show a success indicator + console.log("Quick capture:", idea); + setIdea(""); + }; + + const goToTasks = () => { + router.push("/tasks"); + }; + + return ( +
+

Quick Capture

+

+ Quickly jot down ideas or brain dumps +

+
+