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
This commit is contained in:
Jason Woltje
2026-01-29 13:47:03 -06:00
parent 973502f26e
commit f47dd8bc92
66 changed files with 4277 additions and 29 deletions

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

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

View File

@@ -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<string, unknown>;
}

View File

@@ -0,0 +1,3 @@
export { CreateDomainDto } from "./create-domain.dto";
export { UpdateDomainDto } from "./update-domain.dto";
export { QueryDomainsDto } from "./query-domains.dto";

View File

@@ -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;
}

View File

@@ -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<string, unknown>;
}