feat(05-02): add SearchProvider backend with model, CRUD, and defaults
- Prisma model SearchProvider with userId/tenantId scoping
- Three default providers (Google/Bing/DuckDuckGo) as constants, always returned without DB seed
- GET/POST/DELETE search-providers endpoints on DashboardController
- Ownership verification on delete (T-05-07), default providers cannot be deleted
- CreateSearchProviderDto with class-validator: urlTemplate must contain {query} (T-05-08)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,3 +134,15 @@ model WidgetInstance {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SearchProvider {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String?
|
||||||
|
tenantId String?
|
||||||
|
name String
|
||||||
|
urlTemplate String
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||||
import { SaveLayoutDto } from './dto/save-layout.dto';
|
import { SaveLayoutDto } from './dto/save-layout.dto';
|
||||||
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||||
@@ -30,6 +31,9 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
|||||||
* - POST /dashboard/widgets — create a new widget instance
|
* - POST /dashboard/widgets — create a new widget instance
|
||||||
* - PATCH /dashboard/widgets/:id/config — update widget config
|
* - PATCH /dashboard/widgets/:id/config — update widget config
|
||||||
* - DELETE /dashboard/widgets/:id — remove a widget instance
|
* - DELETE /dashboard/widgets/:id — remove a widget instance
|
||||||
|
* - GET /dashboard/search-providers — list default + user's custom providers
|
||||||
|
* - POST /dashboard/search-providers — create a custom search provider
|
||||||
|
* - DELETE /dashboard/search-providers/:id — remove a custom provider
|
||||||
*/
|
*/
|
||||||
@Controller('dashboard')
|
@Controller('dashboard')
|
||||||
export class DashboardController {
|
export class DashboardController {
|
||||||
@@ -92,4 +96,30 @@ export class DashboardController {
|
|||||||
const { userId } = this.extractContext(req);
|
const { userId } = this.extractContext(req);
|
||||||
return this.dashboardService.removeWidget(id, userId);
|
return this.dashboardService.removeWidget(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Search Providers (05-02, D-15) ---
|
||||||
|
|
||||||
|
@Get('search-providers')
|
||||||
|
async getSearchProviders(@Req() req: Request) {
|
||||||
|
const { userId } = this.extractContext(req);
|
||||||
|
return this.dashboardService.getSearchProviders(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('search-providers')
|
||||||
|
async addSearchProvider(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Body() dto: CreateSearchProviderDto,
|
||||||
|
) {
|
||||||
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
|
return this.dashboardService.addSearchProvider(userId, tenantId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('search-providers/:id')
|
||||||
|
async removeSearchProvider(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const { userId } = this.extractContext(req);
|
||||||
|
return this.dashboardService.removeSearchProvider(id, userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,46 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||||
import { SaveLayoutDto } from './dto/save-layout.dto';
|
import { SaveLayoutDto } from './dto/save-layout.dto';
|
||||||
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default search providers (D-15).
|
||||||
|
* Returned as part of getSearchProviders even when no DB rows exist.
|
||||||
|
* userId null = global defaults — cannot be deleted by users.
|
||||||
|
*/
|
||||||
|
const DEFAULT_SEARCH_PROVIDERS = [
|
||||||
|
{
|
||||||
|
id: 'google',
|
||||||
|
userId: null,
|
||||||
|
tenantId: null,
|
||||||
|
name: 'Google',
|
||||||
|
urlTemplate: 'https://www.google.com/search?q={query}',
|
||||||
|
isDefault: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bing',
|
||||||
|
userId: null,
|
||||||
|
tenantId: null,
|
||||||
|
name: 'Bing',
|
||||||
|
urlTemplate: 'https://www.bing.com/search?q={query}',
|
||||||
|
isDefault: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ddg',
|
||||||
|
userId: null,
|
||||||
|
tenantId: null,
|
||||||
|
name: 'DuckDuckGo',
|
||||||
|
urlTemplate: 'https://duckduckgo.com/?q={query}',
|
||||||
|
isDefault: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service managing per-user dashboard layouts and widget instances.
|
* Service managing per-user dashboard layouts and widget instances.
|
||||||
*
|
*
|
||||||
@@ -126,4 +162,59 @@ export class DashboardService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Search Providers (05-02, D-15) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the three default providers merged with any user-custom providers.
|
||||||
|
* Defaults are always returned even with an empty DB (no seed migration needed).
|
||||||
|
*/
|
||||||
|
async getSearchProviders(userId: string) {
|
||||||
|
const custom = await this.prisma.searchProvider.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...DEFAULT_SEARCH_PROVIDERS, ...custom];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a user-custom search provider.
|
||||||
|
*/
|
||||||
|
async addSearchProvider(
|
||||||
|
userId: string,
|
||||||
|
tenantId: string,
|
||||||
|
dto: CreateSearchProviderDto,
|
||||||
|
) {
|
||||||
|
return this.prisma.searchProvider.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
urlTemplate: dto.urlTemplate,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a user-custom search provider.
|
||||||
|
* Verifies ownership — default providers (userId null) cannot be deleted (T-05-07).
|
||||||
|
*/
|
||||||
|
async removeSearchProvider(id: string, userId: string) {
|
||||||
|
// Default providers have hardcoded IDs that won't exist in DB
|
||||||
|
const provider = await this.prisma.searchProvider.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!provider || provider.userId !== userId) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Search provider with id '${id}' not found`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.searchProvider.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a custom search provider (D-15).
|
||||||
|
* urlTemplate must contain {query} placeholder for the search term.
|
||||||
|
*/
|
||||||
|
export class CreateSearchProviderDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/\{query\}/, {
|
||||||
|
message: 'urlTemplate must contain {query}',
|
||||||
|
})
|
||||||
|
urlTemplate!: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user