From 140cd7acb705e6e0712adbbc2df997bc71478224 Mon Sep 17 00:00:00 2001 From: JoeBot Date: Thu, 2 Apr 2026 17:20:37 -0400 Subject: [PATCH 1/2] feat: add ideation feature with per-tenant toggle Adds idea submission capability gated by a per-tenant feature flag. Super admins can enable/disable ideation for specific tenants via the admin tenant detail drawer. Users see a lightbulb icon in the header when enabled, opening a modal to submit ideas (title + description). Ideas are stored in shared schema for cross-tenant backlog querying. - Database: shared.ideas table (018-ideas.sql migration) - Backend: Ideas NestJS module (entity, service, controller) - Admin API: GET /admin/ideas, PUT /admin/ideas/:id/status, PUT /admin/organizations/:id/settings - Frontend: IdeaModal component, lightbulb ActionIcon in header - Admin UI: Feature Toggles card with ideation Switch in drawer Co-Authored-By: Claude Opus 4.6 --- backend/src/app.module.ts | 2 + backend/src/modules/auth/admin.controller.ts | 32 ++++++++ backend/src/modules/auth/auth.module.ts | 2 + .../src/modules/ideas/dto/create-idea.dto.ts | 12 +++ .../src/modules/ideas/entities/idea.entity.ts | 46 +++++++++++ backend/src/modules/ideas/ideas.controller.ts | 27 +++++++ backend/src/modules/ideas/ideas.module.ts | 14 ++++ backend/src/modules/ideas/ideas.service.ts | 78 +++++++++++++++++++ db/migrations/018-ideas.sql | 15 ++++ frontend/src/components/ideas/IdeaModal.tsx | 69 ++++++++++++++++ frontend/src/components/layout/AppLayout.tsx | 16 ++++ frontend/src/pages/admin/AdminPage.tsx | 33 +++++++- 12 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 backend/src/modules/ideas/dto/create-idea.dto.ts create mode 100644 backend/src/modules/ideas/entities/idea.entity.ts create mode 100644 backend/src/modules/ideas/ideas.controller.ts create mode 100644 backend/src/modules/ideas/ideas.module.ts create mode 100644 backend/src/modules/ideas/ideas.service.ts create mode 100644 db/migrations/018-ideas.sql create mode 100644 frontend/src/components/ideas/IdeaModal.tsx diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3154b6d..e05aafd 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -33,6 +33,7 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod import { BillingModule } from './modules/billing/billing.module'; import { EmailModule } from './modules/email/email.module'; import { OnboardingModule } from './modules/onboarding/onboarding.module'; +import { IdeasModule } from './modules/ideas/ideas.module'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ @@ -88,6 +89,7 @@ import { ScheduleModule } from '@nestjs/schedule'; BillingModule, EmailModule, OnboardingModule, + IdeasModule, ScheduleModule.forRoot(), ], controllers: [AppController], diff --git a/backend/src/modules/auth/admin.controller.ts b/backend/src/modules/auth/admin.controller.ts index 4a4a68e..aaf8c2d 100644 --- a/backend/src/modules/auth/admin.controller.ts +++ b/backend/src/modules/auth/admin.controller.ts @@ -5,6 +5,7 @@ import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { OrganizationsService } from '../organizations/organizations.service'; import { AdminAnalyticsService } from './admin-analytics.service'; +import { IdeasService } from '../ideas/ideas.service'; import * as bcrypt from 'bcryptjs'; @ApiTags('admin') @@ -17,6 +18,7 @@ export class AdminController { private usersService: UsersService, private orgService: OrganizationsService, private analyticsService: AdminAnalyticsService, + private ideasService: IdeasService, ) {} private async requireSuperadmin(req: any) { @@ -196,4 +198,34 @@ export class AdminController { return { success: true, organization: org }; } + + // ── Ideation ── + + @Get('ideas') + async listAllIdeas(@Req() req: any) { + await this.requireSuperadmin(req); + return this.ideasService.findAll(); + } + + @Put('ideas/:id/status') + async updateIdeaStatus( + @Req() req: any, + @Param('id') id: string, + @Body() body: { status: string }, + ) { + await this.requireSuperadmin(req); + const idea = await this.ideasService.updateStatus(id, body.status); + return { success: true, idea }; + } + + @Put('organizations/:id/settings') + async updateOrgSettings( + @Req() req: any, + @Param('id') id: string, + @Body() body: Record, + ) { + await this.requireSuperadmin(req); + const org = await this.orgService.updateSettings(id, body); + return { success: true, organization: org }; + } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 66bb361..188ebd9 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -17,11 +17,13 @@ import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; import { UsersModule } from '../users/users.module'; import { OrganizationsModule } from '../organizations/organizations.module'; +import { IdeasModule } from '../ideas/ideas.module'; @Module({ imports: [ UsersModule, OrganizationsModule, + IdeasModule, PassportModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/modules/ideas/dto/create-idea.dto.ts b/backend/src/modules/ideas/dto/create-idea.dto.ts new file mode 100644 index 0000000..ab7a255 --- /dev/null +++ b/backend/src/modules/ideas/dto/create-idea.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; + +export class CreateIdeaDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + title: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend/src/modules/ideas/entities/idea.entity.ts b/backend/src/modules/ideas/entities/idea.entity.ts new file mode 100644 index 0000000..2d96657 --- /dev/null +++ b/backend/src/modules/ideas/entities/idea.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Organization } from '../../organizations/entities/organization.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity({ schema: 'shared', name: 'ideas' }) +export class Idea { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'org_id' }) + orgId: string; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ length: 255 }) + title: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ length: 20, default: 'new' }) + status: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'org_id' }) + organization: Organization; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user: User; +} diff --git a/backend/src/modules/ideas/ideas.controller.ts b/backend/src/modules/ideas/ideas.controller.ts new file mode 100644 index 0000000..b4a395b --- /dev/null +++ b/backend/src/modules/ideas/ideas.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { IdeasService } from './ideas.service'; +import { CreateIdeaDto } from './dto/create-idea.dto'; + +@ApiTags('ideas') +@Controller('ideas') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +export class IdeasController { + constructor(private ideasService: IdeasService) {} + + @Post() + async create(@Req() req: any, @Body() dto: CreateIdeaDto) { + const orgId = req.user.orgId; + const userId = req.user.userId || req.user.sub; + const idea = await this.ideasService.create(orgId, userId, dto); + return { success: true, idea }; + } + + @Get() + async findByOrg(@Req() req: any) { + const orgId = req.user.orgId; + return this.ideasService.findByOrg(orgId); + } +} diff --git a/backend/src/modules/ideas/ideas.module.ts b/backend/src/modules/ideas/ideas.module.ts new file mode 100644 index 0000000..c3a7fcc --- /dev/null +++ b/backend/src/modules/ideas/ideas.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Idea } from './entities/idea.entity'; +import { Organization } from '../organizations/entities/organization.entity'; +import { IdeasController } from './ideas.controller'; +import { IdeasService } from './ideas.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Idea, Organization])], + controllers: [IdeasController], + providers: [IdeasService], + exports: [IdeasService], +}) +export class IdeasModule {} diff --git a/backend/src/modules/ideas/ideas.service.ts b/backend/src/modules/ideas/ideas.service.ts new file mode 100644 index 0000000..c081986 --- /dev/null +++ b/backend/src/modules/ideas/ideas.service.ts @@ -0,0 +1,78 @@ +import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Idea } from './entities/idea.entity'; +import { Organization } from '../organizations/entities/organization.entity'; +import { CreateIdeaDto } from './dto/create-idea.dto'; + +@Injectable() +export class IdeasService { + constructor( + @InjectRepository(Idea) + private ideasRepository: Repository, + @InjectRepository(Organization) + private orgRepository: Repository, + ) {} + + async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise { + const org = await this.orgRepository.findOne({ where: { id: orgId } }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + if (org.settings?.ideationEnabled !== true) { + throw new ForbiddenException('Ideation is not enabled for this organization'); + } + + const idea = this.ideasRepository.create({ + orgId, + userId, + title: dto.title, + description: dto.description, + }); + return this.ideasRepository.save(idea); + } + + async findByOrg(orgId: string): Promise { + return this.ideasRepository.find({ + where: { orgId }, + order: { createdAt: 'DESC' }, + }); + } + + async findAll(): Promise { + return this.ideasRepository + .createQueryBuilder('idea') + .leftJoin('idea.organization', 'org') + .leftJoin('idea.user', 'user') + .select([ + 'idea.id AS id', + 'idea.title AS title', + 'idea.description AS description', + 'idea.status AS status', + 'idea.createdAt AS "createdAt"', + 'org.id AS "orgId"', + 'org.name AS "orgName"', + 'user.id AS "userId"', + 'user.email AS "userEmail"', + 'user.firstName AS "userFirstName"', + 'user.lastName AS "userLastName"', + ]) + .orderBy('idea.createdAt', 'DESC') + .getRawMany(); + } + + async updateStatus(id: string, status: string): Promise { + const validStatuses = ['new', 'reviewed', 'accepted', 'rejected']; + if (!validStatuses.includes(status)) { + throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`); + } + + const idea = await this.ideasRepository.findOne({ where: { id } }); + if (!idea) { + throw new NotFoundException('Idea not found'); + } + + idea.status = status; + return this.ideasRepository.save(idea); + } +} diff --git a/db/migrations/018-ideas.sql b/db/migrations/018-ideas.sql new file mode 100644 index 0000000..9d1f9d6 --- /dev/null +++ b/db/migrations/018-ideas.sql @@ -0,0 +1,15 @@ +-- Ideation feature: shared ideas table for cross-tenant idea submissions +CREATE TABLE IF NOT EXISTS shared.ideas ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'new', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id); +CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status); +CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC); diff --git a/frontend/src/components/ideas/IdeaModal.tsx b/frontend/src/components/ideas/IdeaModal.tsx new file mode 100644 index 0000000..ff74887 --- /dev/null +++ b/frontend/src/components/ideas/IdeaModal.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { useMutation } from '@tanstack/react-query'; +import api from '../../services/api'; + +interface IdeaModalProps { + opened: boolean; + onClose: () => void; +} + +export function IdeaModal({ opened, onClose }: IdeaModalProps) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + + const submitIdea = useMutation({ + mutationFn: async () => { + const { data } = await api.post('/ideas', { title, description }); + return data; + }, + onSuccess: () => { + notifications.show({ message: 'Idea submitted — thank you!', color: 'green' }); + setTitle(''); + setDescription(''); + onClose(); + }, + onError: (err: any) => { + notifications.show({ + message: err.response?.data?.message || 'Failed to submit idea', + color: 'red', + }); + }, + }); + + const handleClose = () => { + setTitle(''); + setDescription(''); + onClose(); + }; + + return ( + + + setTitle(e.currentTarget.value)} + maxLength={255} + /> +