From 1030e8cfa195d12b9b8e653c9398671081195218 Mon Sep 17 00:00:00 2001 From: Wilfred007 Date: Tue, 29 Sep 2026 23:09:24 +0100 Subject: [PATCH] feat: add AI Studio controller/routes gated by per-artist enable flag --- src/app.ts | 4 + src/controllers/AIStudioController.ts | 141 ++++++++++++++++++ src/entities/User.ts | 4 + .../1757500000000-AddAiStudioEnabledToUser.ts | 17 +++ src/routes/aiStudioRoutes.ts | 22 +++ 5 files changed, 188 insertions(+) create mode 100644 src/controllers/AIStudioController.ts create mode 100644 src/migrations/1757500000000-AddAiStudioEnabledToUser.ts create mode 100644 src/routes/aiStudioRoutes.ts diff --git a/src/app.ts b/src/app.ts index e2961c0..0de4b4d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,6 +26,7 @@ import commentRoutes from "./routes/commentRoutes"; import commentReactionRoutes from "./routes/commentReactionRoutes"; import subscriptionRoutes from "./routes/subscriptionRoutes"; import aiRoutes from "./routes/aiRoutes"; +import aiStudioRoutes from "./routes/aiStudioRoutes"; import activityRoutes from "./routes/activityRoutes"; import activityStreamRoutes from "./routes/activityStreamRoutes"; import accountRoutes from "./routes/accountRoutes"; @@ -149,6 +150,9 @@ app.use("/api/subscriptions", subscriptionRoutes); // AI-assisted generation (cover art, descriptions) — async, queued via JobQueueService app.use("/api/ai", aiRoutes); +// AI Studio: grouped AI-assisted upload tools, gated by per-artist enable flag +app.use("/api/ai-studio", aiStudioRoutes); + // On-chain activity & feeds (REST queries + live SSE stream) app.use("/api/activity", activityRoutes); app.use("/api/activity", activityStreamRoutes); diff --git a/src/controllers/AIStudioController.ts b/src/controllers/AIStudioController.ts new file mode 100644 index 0000000..e8c1b07 --- /dev/null +++ b/src/controllers/AIStudioController.ts @@ -0,0 +1,141 @@ +import { Request, Response, NextFunction } from 'express'; +import { AiGenerationService } from '../services/ai/AiGenerationService'; +import { TagSuggestionService } from '../services/TagSuggestionService'; +import { TweetDraftService } from '../services/TweetDraftService'; +import AppDataSource from '../config/db'; +import { User } from '../entities/User'; +import { AiGenerationRecord } from '../entities/AiGenerationRecord'; +import { AppError } from '../errors/AppError'; +import { handleError } from '../utils/helpers'; +import { HTTP_STATUS } from '../config/constants'; +import { routeParam } from '../utils/routeParams'; + +const aiGenerationService = new AiGenerationService(); +const tagSuggestionService = new TagSuggestionService(); +const tweetDraftService = new TweetDraftService(); + +/** Rejects requests when the artist has not opted into AI Studio. */ +export const requireAiStudioEnabled = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const userId = (req as any).user?.id as string | undefined; + if (!userId) throw AppError.authentication('Authentication required'); + const user = await AppDataSource.getRepository(User).findOneBy({ id: userId }); + if (!user || !user.aiStudioEnabled) { + throw AppError.authorization( + 'AI Studio is not enabled for this artist', + undefined, + 'AI_STUDIO_DISABLED', + ); + } + next(); + } catch (error) { + handleError(req, res, error); + } +}; + +/** + * Single place for artists to see/manage AI-assisted upload tools, + * gated by the per-artist `aiStudioEnabled` flag. + */ +export class AIStudioController { + getStatus = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const user = await AppDataSource.getRepository(User).findOneBy({ id: userId }); + if (!user) throw AppError.notFound('User not found'); + res + .status(HTTP_STATUS.OK) + .json({ success: true, data: { aiStudioEnabled: user.aiStudioEnabled } }); + } catch (error) { + handleError(req, res, error); + } + }; + + setEnabled = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const enabled = req.body?.enabled as boolean; + if (typeof enabled !== 'boolean') throw AppError.validation('`enabled` must be a boolean'); + const repo = AppDataSource.getRepository(User); + const user = await repo.findOneBy({ id: userId }); + if (!user) throw AppError.notFound('User not found'); + user.aiStudioEnabled = enabled; + await repo.save(user); + res + .status(HTTP_STATUS.OK) + .json({ success: true, data: { aiStudioEnabled: user.aiStudioEnabled } }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** Overview: recent AI generations + tag/draft entry points. */ + getOverview = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const records = await AppDataSource.getRepository(AiGenerationRecord).find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: 20, + }); + const drafts = await tweetDraftService.listDrafts(userId); + res.status(HTTP_STATUS.OK).json({ + success: true, + data: { generations: records, tweetDrafts: drafts }, + }); + } catch (error) { + handleError(req, res, error); + } + }; + + requestCoverArt = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const songId = routeParam(req.params.songId); + const record = await aiGenerationService.requestGeneration('coverArt', songId, userId); + res.status(HTTP_STATUS.CREATED).json({ success: true, data: record }); + } catch (error) { + handleError(req, res, error); + } + }; + + requestDescription = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const songId = routeParam(req.params.songId); + const record = await aiGenerationService.requestGeneration('descriptions', songId, userId); + res.status(HTTP_STATUS.CREATED).json({ success: true, data: record }); + } catch (error) { + handleError(req, res, error); + } + }; + + suggestTags = async (req: Request, res: Response): Promise => { + try { + const { title, description } = req.body; + const user = (req as any).user; + const suggestions = await tagSuggestionService.suggestTagsAndGenres({ + title, + description, + artistName: user?.username || user?.name, + }); + res.status(HTTP_STATUS.OK).json({ success: true, data: suggestions }); + } catch (error) { + handleError(req, res, error); + } + }; + + createTweetDraft = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const draft = await tweetDraftService.createDraft(userId, req.body?.songId); + res.status(HTTP_STATUS.CREATED).json({ success: true, data: draft }); + } catch (error) { + handleError(req, res, error); + } + }; +} diff --git a/src/entities/User.ts b/src/entities/User.ts index dd33df2..8563a16 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -170,6 +170,10 @@ export class User { @Column({ default: true }) isProfilePublic!: boolean; + /** Per-artist opt-in flag gating AI Studio upload tools (cover art, descriptions, tag suggestions, draft tweets). */ + @Column({ default: false }) + aiStudioEnabled!: boolean; + // ── Account lifecycle (Issue #633, GDPR Art. 12/17) ───────────────────────── // A deletion request does not erase immediately. The user gets a grace window // in which they can export their data or cancel, after which a background job diff --git a/src/migrations/1757500000000-AddAiStudioEnabledToUser.ts b/src/migrations/1757500000000-AddAiStudioEnabledToUser.ts new file mode 100644 index 0000000..3f2a1c1 --- /dev/null +++ b/src/migrations/1757500000000-AddAiStudioEnabledToUser.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Adds the per-artist `aiStudioEnabled` opt-in flag gating AI Studio tools. + */ +export class AddAiStudioEnabledToUser1757500000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'users', + new TableColumn({ name: 'aiStudioEnabled', type: 'boolean', default: false }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('users', 'aiStudioEnabled'); + } +} diff --git a/src/routes/aiStudioRoutes.ts b/src/routes/aiStudioRoutes.ts new file mode 100644 index 0000000..210df6b --- /dev/null +++ b/src/routes/aiStudioRoutes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { AIStudioController, requireAiStudioEnabled } from '../controllers/AIStudioController'; +import { authArtistMiddleware } from '../middlewares/authMiddleware'; + +const router = Router(); +const controller = new AIStudioController(); + +router.use(authArtistMiddleware); + +// Flag management is always reachable so artists can opt in/out. +router.get('/status', controller.getStatus); +router.patch('/status', controller.setEnabled); + +// All AI-assisted upload tools below require the per-artist enable flag. +router.use(requireAiStudioEnabled); +router.get('/overview', controller.getOverview); +router.post('/songs/:songId/cover-art', controller.requestCoverArt); +router.post('/songs/:songId/description', controller.requestDescription); +router.post('/suggest-tags', controller.suggestTags); +router.post('/tweet-draft', controller.createTweetDraft); + +export default router;