clear old assets, new ci/cd flow
This commit is contained in:
@@ -11,6 +11,8 @@ import { correlationRouter } from "./routers/correlation";
|
||||
import { reportsRouter } from "./routers/reports";
|
||||
import { schedulerRouter } from "./routers/scheduler";
|
||||
import { extensionRouter } from "./routers/extension";
|
||||
import { blogRouter } from "./routers/blog";
|
||||
import { adminRouter } from "./routers/admin";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -27,6 +29,8 @@ export const appRouter = createTRPCRouter({
|
||||
reports: reportsRouter,
|
||||
scheduler: schedulerRouter,
|
||||
extension: extensionRouter,
|
||||
blog: blogRouter,
|
||||
admin: adminRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
151
web/src/server/api/routers/admin.ts
Normal file
151
web/src/server/api/routers/admin.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { object, string, boolean, minLength, optional } from "valibot";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, adminProcedure } from "~/server/api/utils";
|
||||
import { blogPosts } from "~/server/db/schema/marketing";
|
||||
import { users } from "~/server/db/schema/auth";
|
||||
import { eq, desc, count, sql } from "drizzle-orm";
|
||||
|
||||
const CreateBlogInput = wrap(
|
||||
object({
|
||||
title: string([minLength(1)]),
|
||||
slug: string([minLength(1)]),
|
||||
excerpt: optional(string()),
|
||||
content: string([minLength(1)]),
|
||||
authorName: optional(string()),
|
||||
coverImageUrl: optional(string()),
|
||||
tags: optional(string()),
|
||||
published: optional(boolean()),
|
||||
featured: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
const UpdateBlogInput = wrap(
|
||||
object({
|
||||
id: string(),
|
||||
title: optional(string([minLength(1)])),
|
||||
slug: optional(string([minLength(1)])),
|
||||
excerpt: optional(string()),
|
||||
content: optional(string([minLength(1)])),
|
||||
authorName: optional(string()),
|
||||
coverImageUrl: optional(string()),
|
||||
tags: optional(string()),
|
||||
published: optional(boolean()),
|
||||
featured: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
export const adminRouter = createTRPCRouter({
|
||||
// --- Dashboard ---
|
||||
stats: adminProcedure.query(async ({ ctx }) => {
|
||||
const [{ userCount }] = await ctx.db.select({ userCount: count() }).from(users);
|
||||
const [{ postCount }] = await ctx.db
|
||||
.select({ postCount: count() })
|
||||
.from(blogPosts)
|
||||
.where(eq(blogPosts.published, true));
|
||||
const [{ totalViews }] = await ctx.db
|
||||
.select({ totalViews: sql<number>`${count()}` })
|
||||
.from(blogPosts);
|
||||
const recentPosts = await ctx.db
|
||||
.select({ id: blogPosts.id, title: blogPosts.title, publishedAt: blogPosts.publishedAt })
|
||||
.from(blogPosts)
|
||||
.orderBy(desc(blogPosts.createdAt))
|
||||
.limit(5);
|
||||
return { userCount, postCount, totalViews, recentPosts };
|
||||
}),
|
||||
|
||||
// --- Blog ---
|
||||
blogList: adminProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.select().from(blogPosts).orderBy(desc(blogPosts.createdAt));
|
||||
}),
|
||||
|
||||
blogGet: adminProcedure
|
||||
.input(wrap(object({ id: string() })))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const post = await ctx.db
|
||||
.select().from(blogPosts)
|
||||
.where(eq(blogPosts.id, input.id)).limit(1);
|
||||
return post[0] ?? null;
|
||||
}),
|
||||
|
||||
blogCreate: adminProcedure
|
||||
.input(CreateBlogInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db
|
||||
.select({ id: blogPosts.id }).from(blogPosts)
|
||||
.where(eq(blogPosts.slug, input.slug)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Slug already exists" });
|
||||
}
|
||||
const tags = input.tags
|
||||
? input.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
const [newPost] = await ctx.db
|
||||
.insert(blogPosts)
|
||||
.values({
|
||||
title: input.title,
|
||||
slug: input.slug,
|
||||
excerpt: input.excerpt,
|
||||
content: input.content,
|
||||
authorName: input.authorName,
|
||||
coverImageUrl: input.coverImageUrl,
|
||||
tags,
|
||||
published: input.published ?? false,
|
||||
featured: input.featured ?? false,
|
||||
publishedAt: input.published ? new Date() : undefined,
|
||||
}).returning();
|
||||
return newPost;
|
||||
}),
|
||||
|
||||
blogUpdate: adminProcedure
|
||||
.input(UpdateBlogInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...updates } = input;
|
||||
const existing = await ctx.db
|
||||
.select().from(blogPosts)
|
||||
.where(eq(blogPosts.id, id)).limit(1);
|
||||
if (existing.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" });
|
||||
}
|
||||
const set: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
if (key === "tags" && typeof value === "string") {
|
||||
set[key] = value.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
} else {
|
||||
set[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (set.published) {
|
||||
set.publishedAt = new Date();
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
.update(blogPosts).set(set)
|
||||
.where(eq(blogPosts.id, id)).returning();
|
||||
return updated;
|
||||
}),
|
||||
|
||||
blogDelete: adminProcedure
|
||||
.input(wrap(object({ id: string() })))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(blogPosts).where(eq(blogPosts.id, input.id));
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// --- Users ---
|
||||
userList: adminProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db
|
||||
.select({ id: users.id, email: users.email, name: users.name, role: users.role, createdAt: users.createdAt })
|
||||
.from(users).orderBy(desc(users.createdAt));
|
||||
}),
|
||||
|
||||
userUpdateRole: adminProcedure
|
||||
.input(wrap(object({ id: string(), role: string() })))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [updated] = await ctx.db
|
||||
.update(users).set({ role: input.role })
|
||||
.where(eq(users.id, input.id)).returning();
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
95
web/src/server/api/routers/blog.ts
Normal file
95
web/src/server/api/routers/blog.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { object, string, optional } from "valibot";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
|
||||
import { blogPosts } from "~/server/db/schema/marketing";
|
||||
import { eq, and, desc, count, sql } from "drizzle-orm";
|
||||
|
||||
export const blogRouter = createTRPCRouter({
|
||||
list: publicProcedure
|
||||
.input(
|
||||
wrap(
|
||||
object({
|
||||
tag: optional(string()),
|
||||
limit: optional(string()),
|
||||
offset: optional(string()),
|
||||
})
|
||||
)
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { tag, limit, offset } = input ?? {};
|
||||
const lim = limit ? parseInt(limit, 10) : 12;
|
||||
const off = offset ? parseInt(offset, 10) : 0;
|
||||
const conditions = [eq(blogPosts.published, true)];
|
||||
if (tag) {
|
||||
conditions.push(sql`${blogPosts.tags} LIKE ${`%${tag}%`}`);
|
||||
}
|
||||
const where = conditions.length > 1 ? and(...conditions) : conditions[0];
|
||||
|
||||
const posts = await ctx.db
|
||||
.select()
|
||||
.from(blogPosts)
|
||||
.where(where)
|
||||
.orderBy(desc(blogPosts.publishedAt))
|
||||
.limit(lim)
|
||||
.offset(off);
|
||||
|
||||
const [{ total }] = await ctx.db
|
||||
.select({ total: count() })
|
||||
.from(blogPosts)
|
||||
.where(where);
|
||||
|
||||
return { posts, total };
|
||||
}),
|
||||
|
||||
bySlug: publicProcedure
|
||||
.input(wrap(object({ slug: string() })))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const post = await ctx.db
|
||||
.select()
|
||||
.from(blogPosts)
|
||||
.where(and(eq(blogPosts.slug, input.slug), eq(blogPosts.published, true)))
|
||||
.limit(1);
|
||||
|
||||
if (post.length === 0) return null;
|
||||
|
||||
await ctx.db
|
||||
.update(blogPosts)
|
||||
.set({ viewCount: sql`${blogPosts.viewCount} + 1` })
|
||||
.where(eq(blogPosts.id, post[0].id));
|
||||
|
||||
const currentTags = post[0].tags as string[];
|
||||
const related = await ctx.db
|
||||
.select()
|
||||
.from(blogPosts)
|
||||
.where(
|
||||
and(
|
||||
eq(blogPosts.published, true),
|
||||
sql`${blogPosts.id} != ${post[0].id}`,
|
||||
sql`${blogPosts.tags} LIKE ${`%${currentTags[0]}%`}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(blogPosts.publishedAt))
|
||||
.limit(2);
|
||||
|
||||
return { post: post[0], related };
|
||||
}),
|
||||
|
||||
tags: publicProcedure.query(async ({ ctx }) => {
|
||||
const posts = await ctx.db
|
||||
.select({ tags: blogPosts.tags })
|
||||
.from(blogPosts)
|
||||
.where(eq(blogPosts.published, true));
|
||||
|
||||
const tagCounts = new Map<string, number>();
|
||||
for (const row of posts) {
|
||||
const tags = row.tags as string[];
|
||||
for (const tag of tags) {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tagCounts.entries())
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user