clear old assets, new ci/cd flow

This commit is contained in:
2026-05-26 11:54:41 -04:00
parent 82815009c9
commit 72609755f8
87 changed files with 4132 additions and 7158 deletions

View File

@@ -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;

View 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;
}),
});

View 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);
}),
});