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

View File

@@ -0,0 +1,2 @@
export { CreateLayoutDto } from "./create-layout.dto";
export { UpdateLayoutDto } from "./update-layout.dto";

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>;
}
/**
* Layout configuration type
*/
export type Layout = ConfiguredWidget[];