feat: role-based ACL + admin management UI
Backend: - Add acl.ts with requireAuth/requireRole/requireOwnerOrAdmin helpers - Gate premium videos from unauthenticated users in videos query/resolver - Fix updateVideoPlay to verify ownership before updating - Add admin mutations: adminListUsers, adminUpdateUser, adminDeleteUser - Add admin mutations: createVideo, updateVideo, deleteVideo, setVideoModels, adminListVideos - Add admin mutations: createArticle, updateArticle, deleteArticle, adminListArticles - Add deleteComment mutation (owner or admin only) - Add AdminUserListType to GraphQL types - Fix featured filter on articles query Frontend: - Install marked for markdown rendering - Add /admin/* section with sidebar layout and admin-only guard - Admin users page: paginated table with search, role filter, inline role change, delete - Admin videos pages: list, create form, edit form with file upload and model assignment - Admin articles pages: list, create form, edit form with split-pane markdown editor - Add admin nav link in header (desktop + mobile) for admin users - Render article content through marked in magazine detail page - Add all admin GraphQL service functions to services.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,27 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder";
|
||||
import { ArticleType } from "../types/index";
|
||||
import { articles, users } from "../../db/schema/index";
|
||||
import { eq, and, lte, desc } from "drizzle-orm";
|
||||
import { requireRole } from "../../lib/acl";
|
||||
|
||||
async function enrichArticle(db: any, article: any) {
|
||||
let author = null;
|
||||
if (article.author) {
|
||||
const authorUser = await db
|
||||
.select({
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article.author))
|
||||
.limit(1);
|
||||
author = authorUser[0] || null;
|
||||
}
|
||||
return { ...article, author };
|
||||
}
|
||||
|
||||
builder.queryField("articles", (t) =>
|
||||
t.field({
|
||||
@@ -11,10 +31,16 @@ builder.queryField("articles", (t) =>
|
||||
limit: t.arg.int(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const dateFilter = lte(articles.publish_date, new Date());
|
||||
const whereCondition =
|
||||
args.featured !== null && args.featured !== undefined
|
||||
? and(dateFilter, eq(articles.featured, args.featured))
|
||||
: dateFilter;
|
||||
|
||||
let query = ctx.db
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(lte(articles.publish_date, new Date()))
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(articles.publish_date));
|
||||
|
||||
if (args.limit) {
|
||||
@@ -22,26 +48,7 @@ builder.queryField("articles", (t) =>
|
||||
}
|
||||
|
||||
const articleList = await query;
|
||||
|
||||
return Promise.all(
|
||||
articleList.map(async (article: any) => {
|
||||
let author = null;
|
||||
if (article.author) {
|
||||
const authorUser = await ctx.db
|
||||
.select({
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article.author))
|
||||
.limit(1);
|
||||
author = authorUser[0] || null;
|
||||
}
|
||||
return { ...article, author };
|
||||
}),
|
||||
);
|
||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -61,23 +68,114 @@ builder.queryField("article", (t) =>
|
||||
.limit(1);
|
||||
|
||||
if (!article[0]) return null;
|
||||
|
||||
let author = null;
|
||||
if (article[0].author) {
|
||||
const authorUser = await ctx.db
|
||||
.select({
|
||||
first_name: users.first_name,
|
||||
last_name: users.last_name,
|
||||
avatar: users.avatar,
|
||||
description: users.description,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, article[0].author))
|
||||
.limit(1);
|
||||
author = authorUser[0] || null;
|
||||
}
|
||||
|
||||
return { ...article[0], author };
|
||||
return enrichArticle(ctx.db, article[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Admin queries & mutations ────────────────────────────────────────────────
|
||||
|
||||
builder.queryField("adminListArticles", (t) =>
|
||||
t.field({
|
||||
type: [ArticleType],
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
const articleList = await ctx.db
|
||||
.select()
|
||||
.from(articles)
|
||||
.orderBy(desc(articles.publish_date));
|
||||
return Promise.all(articleList.map((article: any) => enrichArticle(ctx.db, article)));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("createArticle", (t) =>
|
||||
t.field({
|
||||
type: ArticleType,
|
||||
args: {
|
||||
title: t.arg.string({ required: true }),
|
||||
slug: t.arg.string({ required: true }),
|
||||
excerpt: t.arg.string(),
|
||||
content: t.arg.string(),
|
||||
imageId: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
category: t.arg.string(),
|
||||
featured: t.arg.boolean(),
|
||||
publishDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
const inserted = await ctx.db
|
||||
.insert(articles)
|
||||
.values({
|
||||
title: args.title,
|
||||
slug: args.slug,
|
||||
excerpt: args.excerpt || null,
|
||||
content: args.content || null,
|
||||
image: args.imageId || null,
|
||||
tags: args.tags || [],
|
||||
category: args.category || null,
|
||||
featured: args.featured ?? false,
|
||||
publish_date: args.publishDate ? new Date(args.publishDate) : new Date(),
|
||||
author: ctx.currentUser!.id,
|
||||
})
|
||||
.returning();
|
||||
return enrichArticle(ctx.db, inserted[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("updateArticle", (t) =>
|
||||
t.field({
|
||||
type: ArticleType,
|
||||
nullable: true,
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
title: t.arg.string(),
|
||||
slug: t.arg.string(),
|
||||
excerpt: t.arg.string(),
|
||||
content: t.arg.string(),
|
||||
imageId: t.arg.string(),
|
||||
tags: t.arg.stringList(),
|
||||
category: t.arg.string(),
|
||||
featured: t.arg.boolean(),
|
||||
publishDate: t.arg.string(),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
const updates: Record<string, unknown> = { date_updated: new Date() };
|
||||
if (args.title !== undefined && args.title !== null) updates.title = args.title;
|
||||
if (args.slug !== undefined && args.slug !== null) updates.slug = args.slug;
|
||||
if (args.excerpt !== undefined) updates.excerpt = args.excerpt;
|
||||
if (args.content !== undefined) updates.content = args.content;
|
||||
if (args.imageId !== undefined) updates.image = args.imageId;
|
||||
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
|
||||
if (args.category !== undefined) updates.category = args.category;
|
||||
if (args.featured !== undefined && args.featured !== null) updates.featured = args.featured;
|
||||
if (args.publishDate !== undefined && args.publishDate !== null)
|
||||
updates.publish_date = new Date(args.publishDate);
|
||||
|
||||
const updated = await ctx.db
|
||||
.update(articles)
|
||||
.set(updates as any)
|
||||
.where(eq(articles.id, args.id))
|
||||
.returning();
|
||||
if (!updated[0]) return null;
|
||||
return enrichArticle(ctx.db, updated[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("deleteArticle", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
id: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
requireRole(ctx, "admin");
|
||||
await ctx.db.delete(articles).where(eq(articles.id, args.id));
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user