Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
141 changes: 141 additions & 0 deletions src/controllers/AIStudioController.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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);
}
};
}
4 changes: 4 additions & 0 deletions src/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/migrations/1757500000000-AddAiStudioEnabledToUser.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await queryRunner.addColumn(
'users',
new TableColumn({ name: 'aiStudioEnabled', type: 'boolean', default: false }),
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('users', 'aiStudioEnabled');
}
}
22 changes: 22 additions & 0 deletions src/routes/aiStudioRoutes.ts
Original file line number Diff line number Diff line change
@@ -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;