diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b776575..e45aee2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,6 +80,10 @@ model User { passwordResetTokens PasswordResetToken[] updatedCampaigns Campaign[] @relation("CampaignUpdatedBy") + activeThemeId String? @map("active_theme_id") @db.Uuid + activeTheme Theme? @relation("ActiveTheme", fields: [activeThemeId], references: [id], onDelete: SetNull) + themes Theme[] + @@map("users") } @@ -260,3 +264,23 @@ model Session { @@index([isActive]) @@map("sessions") } + +model Theme { + id String @id @default(uuid()) @db.Uuid + authorId String @map("author_id") @db.Uuid + name String + description String? + isPublic Boolean @default(false) @map("is_public") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + User User[] @relation("ActiveTheme") + + colors Json + + @@index([authorId]) + @@index([isPublic]) + @@map("themes") +} diff --git a/src/controllers/themeController.ts b/src/controllers/themeController.ts new file mode 100644 index 0000000..a9cf3f2 --- /dev/null +++ b/src/controllers/themeController.ts @@ -0,0 +1,168 @@ +import { Request, Response } from "express"; +import { prisma } from "../lib/prisma.js"; +import { SessionAuthRequest } from "../lib/sessionAuth.js"; + +export async function getThemes(req: Request, res: Response): Promise { + try { + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.max( + 1, + Math.min(50, parseInt(req.query.limit as string) || 24), + ); + const skip = (page - 1) * limit; + + const where = { isPublic: true }; + + const [totalItems, themes] = await Promise.all([ + prisma.theme.count({ where }), + prisma.theme.findMany({ + where, + include: { + author: { + select: { username: true }, + }, + }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + ]); + + const totalPages = Math.ceil(totalItems / limit); + + res.json({ + themes, + pagination: { + page, + limit, + totalItems, + totalPages, + itemsReturned: themes.length, + }, + }); + } catch (error) { + console.error("Get themes error:", error); + res.status(500).json({ error: "Failed to get themes" }); + } +} + +export async function createTheme( + req: SessionAuthRequest, + res: Response, +): Promise { + try { + const { name, description, isPublic, colors } = req.body; + + const theme = await prisma.theme.create({ + data: { + name, + description: description || null, + isPublic, + authorId: req.user!.id, + colors, + }, + }); + + if (!theme.isPublic) { + await prisma.user.update({ + where: { id: req.user!.id }, + data: { activeThemeId: theme.id }, + }); + res.setHeader( + "Set-Cookie", + `theme=${encodeURIComponent(JSON.stringify(theme.colors))}; path=/; max-age=31536000`, + ); + } + + res.status(201).json(theme); + } catch (error) { + console.error("Create theme error:", error); + res.status(500).json({ error: "Failed to create theme" }); + } +} + +export async function getUserTheme( + req: SessionAuthRequest, + res: Response, +): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + include: { activeTheme: true }, + }); + + if (!user?.activeTheme) { + res.json({ theme: null }); + return; + } + + res.json({ theme: user.activeTheme.colors }); + } catch (error) { + console.error("Get user theme error:", error); + res.status(500).json({ error: "Failed to get user theme" }); + } +} + +export async function updateUserTheme( + req: SessionAuthRequest, + res: Response, +): Promise { + try { + const { themeId } = req.body; + + const theme = await prisma.theme.findUnique({ + where: { id: themeId }, + }); + + if (!theme) { + res.status(404).json({ error: "Theme not found" }); + return; + } + + if (!theme.isPublic && theme.authorId !== req.user!.id) { + res + .status(403) + .json({ error: "Cannot apply a private theme you do not own" }); + return; + } + + const currentUser = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { activeThemeId: true }, + }); + + if (currentUser?.activeThemeId !== theme.id) { + await prisma.user.update({ + where: { id: req.user!.id }, + data: { activeThemeId: theme.id }, + }); + } + + res.json({ success: true, colors: theme.colors }); + } catch (error) { + console.error("Update user theme error:", error); + res.status(500).json({ error: "Failed to update user theme" }); + } +} + +export async function clearUserTheme( + req: SessionAuthRequest, + res: Response, +): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { activeThemeId: true }, + }); + + await prisma.user.update({ + where: { id: req.user!.id }, + data: { activeThemeId: null }, + }); + res.setHeader("Set-Cookie", `theme=; path=/; max-age=0`); + res.json({ success: true }); + } catch (error) { + console.error("Clear user theme error:", error); + res.status(500).json({ error: "Failed to clear user theme" }); + } +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..93f0497 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +const CSSColor = z.string().min(1); + +export const ThemeColorsSchema = z.object({ + primary: CSSColor, + primary100: CSSColor, + primary200: CSSColor, + primary300: CSSColor, + primary400: CSSColor, + primary500: CSSColor, + background: CSSColor, + foreground: CSSColor, + card: CSSColor, + cardForeground: CSSColor, + muted: CSSColor, + mutedForeground: CSSColor, + accent: CSSColor, + accentForeground: CSSColor, + destructive: CSSColor, + border: CSSColor, + input: CSSColor, + ring: CSSColor, +}); + +export type ThemeColors = z.infer; diff --git a/src/middleware/validation.ts b/src/middleware/validation.ts index 9f7a470..72d8ff7 100644 --- a/src/middleware/validation.ts +++ b/src/middleware/validation.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { z, ZodError } from 'zod'; +import { ThemeColorsSchema } from '../lib/theme'; const passwordSchema = z .string() @@ -132,6 +133,21 @@ export const upsertCampaignSchema = z.object({ }), }); +export const createThemeSchema = z.object({ + body: z.object({ + name: z.string().trim().min(1, "Theme name is required").max(100), + description: z.string().trim().max(1000).optional().nullable(), + isPublic: z.boolean(), + colors: ThemeColorsSchema, + }), +}); + +export const updateUserThemeSchema = z.object({ + body: z.object({ + themeId: z.string().trim().min(1, "themeId string is required"), + }), +}); + const formatZodErrors = (error: ZodError) => error.errors.map(({ path, message }) => ({ field: path.filter((p) => p !== 'body').join('.') || 'unknown', diff --git a/src/routes/themes.ts b/src/routes/themes.ts new file mode 100644 index 0000000..6b3a5bd --- /dev/null +++ b/src/routes/themes.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { authenticateSession } from "../lib/sessionAuth.js"; +import { registerRoute } from "../lib/docs.js"; +import { getThemes, createTheme } from "../controllers/themeController.js"; +import { validate, createThemeSchema } from "../middleware/validation.js"; + +const router = Router(); + +router.get("/", getThemes); +router.post("/", authenticateSession, validate(createThemeSchema), createTheme); + +registerRoute({ + method: "GET", + path: "/themes", + summary: "Get public themes", + responses: { + "200": + '{"themes":[{"id":"string","name":"string","author":{"username":"string"},"colors":{}}],"pagination":{"page":1,"limit":24,"totalItems":1,"totalPages":1,"itemsReturned":1}}', + }, +}); + +registerRoute({ + method: "POST", + path: "/themes", + summary: "Create a theme", + auth: true, + responses: { "200": '{"id":"string"}' }, +}); + +export default router; diff --git a/src/routes/user-theme.ts b/src/routes/user-theme.ts new file mode 100644 index 0000000..febfff4 --- /dev/null +++ b/src/routes/user-theme.ts @@ -0,0 +1,21 @@ +import { Router } from "express"; +import { authenticateSession } from "../lib/sessionAuth.js"; +import { + getUserTheme, + updateUserTheme, + clearUserTheme, +} from "../controllers/themeController.js"; +import { validate, updateUserThemeSchema } from "../middleware/validation.js"; + +const router = Router(); + +router.get("/", authenticateSession, getUserTheme); +router.patch( + "/", + authenticateSession, + validate(updateUserThemeSchema), + updateUserTheme, +); +router.delete("/", authenticateSession, clearUserTheme); + +export default router;