Compare commits

...

2 Commits

Author SHA1 Message Date
a1f21c38e4 feat(server): add dashboard library management API with admin middleware
Add admin middleware to enforce authentication and permission checks for dashboard access.

Implement API models and routes for library CRUD operations, including listing, getting, creating, updating, and deleting libraries with validation and error handling.
2025-11-01 15:32:30 +07:00
d6b4fb128f feat(config): enable TypeScript experimental tsgo compiler 2025-11-01 15:21:49 +07:00
7 changed files with 304 additions and 1 deletions

View File

@@ -1,7 +1,7 @@
{
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"typescript.experimental.useTsgo": false,
"typescript.experimental.useTsgo": true,
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "always",
"source.fixAll.biome": "always",

View File

@@ -0,0 +1,36 @@
import { createMiddleware } from "hono/factory";
import { auth } from "@/lib/auth";
import { createHTTPException } from "@/lib/errors";
import type { ExtendHonoEnv } from "@/lib/types";
type AdminEnv = ExtendHonoEnv<{
session: Exclude<Awaited<ReturnType<typeof auth.api.getSession>>, null>;
user: Exclude<Awaited<ReturnType<typeof auth.api.getUser>>, null>;
}>;
export const adminMiddleware = createMiddleware<AdminEnv>(async (c, next) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
throw createHTTPException(401, "You are not authorized");
}
c.set("session", session);
c.set("user", session.user);
const hasPermission = await auth.api.userHasPermission({
body: {
userId: session.user.id,
permissions: {
dashboard: ["access"],
},
},
});
if (!hasPermission) {
throw createHTTPException(
403,
"You do not have permission to access the dashboard",
);
}
return await next();
});

View File

@@ -0,0 +1 @@
export * from "./library";

View File

@@ -0,0 +1,95 @@
import { z } from "zod";
const LIBRARY_TYPES = ["MOVIE", "SERIES"] as const;
// List all libraries
export const LibraryListInput = z.void();
export type LibraryListInput = z.infer<typeof LibraryListInput>;
export const LibraryListOutput = z.array(
z.object({
id: z.number(),
name: z.string(),
type: z.enum(LIBRARY_TYPES),
path: z.string(),
primaryLanguage: z.string(),
fallbackLanguage: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
}),
);
export type LibraryListOutput = z.infer<typeof LibraryListOutput>;
// Get a library by ID
export const LibraryGetInput = z.object({
id: z.number().min(1),
});
export type LibraryGetInput = z.infer<typeof LibraryGetInput>;
export const LibraryGetOutput = z.object({
id: z.number(),
name: z.string(),
type: z.enum(LIBRARY_TYPES),
path: z.string(),
primaryLanguage: z.string(),
fallbackLanguage: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type LibraryGetOutput = z.infer<typeof LibraryGetOutput>;
// Create a new library
export const LibraryCreateInput = z.object({
name: z.string().min(1).max(255),
type: z.enum(LIBRARY_TYPES),
path: z.string().min(1).max(255),
primaryLanguage: z.string().min(1).max(255),
fallbackLanguage: z.string().min(1).max(255),
});
export type LibraryCreateInput = z.infer<typeof LibraryCreateInput>;
export const LibraryCreateOutput = z.object({
success: z.boolean(),
message: z.string(),
data: LibraryGetOutput,
});
export type LibraryCreateOutput = z.infer<typeof LibraryCreateOutput>;
// Update a library
export const LibraryUpdateInputParam = z.object({
id: z.number(),
});
export type LibraryUpdateInputParam = z.infer<typeof LibraryUpdateInputParam>;
export const LibraryUpdateInput = z.object({
name: z.string().min(1).max(255),
type: z.enum(LIBRARY_TYPES),
path: z.string().min(1).max(255),
primaryLanguage: z.string().min(1).max(255),
fallbackLanguage: z.string().min(1).max(255),
});
export type LibraryUpdateInput = z.infer<typeof LibraryUpdateInput>;
export const LibraryUpdateOutput = z.object({
id: z.number(),
name: z.string(),
type: z.enum(LIBRARY_TYPES),
path: z.string(),
primaryLanguage: z.string(),
fallbackLanguage: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type LibraryUpdateOutput = z.infer<typeof LibraryUpdateOutput>;
// Delete a library
export const LibraryDeleteInput = z.object({
id: z.number().min(1),
});
export type LibraryDeleteInput = z.infer<typeof LibraryDeleteInput>;
export const LibraryDeleteOutput = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type LibraryDeleteOutput = z.infer<typeof LibraryDeleteOutput>;

View File

@@ -1,2 +1,6 @@
export * from "./dashboard";
export * from "./library";
export * from "./media";
export * from "./onboard";
export * from "./playback";
export * from "./title";

View File

@@ -0,0 +1,4 @@
import { Hono } from "hono";
import { LibraryRouter } from "./dashboard/library";
export const DashboardRouter = new Hono().route("/library ", LibraryRouter);

View File

@@ -0,0 +1,163 @@
import { zValidator } from "@hono/zod-validator";
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { database } from "@/db";
import { libraryFolders } from "@/db/schema";
import { createHTTPException } from "@/lib/errors";
import {
LibraryCreateInput,
type LibraryCreateOutput,
LibraryDeleteInput,
type LibraryDeleteOutput,
LibraryGetInput,
type LibraryGetOutput,
type LibraryListOutput,
LibraryUpdateInput,
LibraryUpdateInputParam,
type LibraryUpdateOutput,
} from "@/models/api";
export const LibraryRouter = new Hono()
.get("/", async (c) => {
const libraries = await database.select().from(libraryFolders);
return c.json<LibraryListOutput>(libraries);
})
.get("/:id", zValidator("param", LibraryGetInput), async (c) => {
const { id } = c.req.valid("param");
const [library] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.id, id))
.limit(1);
if (!library) {
throw createHTTPException(404, `Library with id ${id} not found.`);
}
return c.json<LibraryGetOutput>(library);
})
.patch(
"/:id",
zValidator("param", LibraryUpdateInputParam),
zValidator("json", LibraryUpdateInput),
async (c) => {
const { id } = c.req.valid("param");
const input = c.req.valid("json");
const [existingLibrary] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.id, id))
.limit(1);
if (!existingLibrary) {
throw createHTTPException(404, `Library with id ${id} not found`);
}
if (input.name) {
const [existingByName] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.name, input.name))
.limit(1);
if (existingByName && existingByName.id !== id) {
throw createHTTPException(
409,
`A library with the name "${input.name}" already exists.`,
);
}
}
if (input.path) {
const [existingByPath] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.path, input.path))
.limit(1);
if (existingByPath && existingByPath.id !== id) {
throw createHTTPException(
409,
`A library with the path "${input.path}" already exists.`,
);
}
}
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.path !== undefined) updateData.path = input.path;
if (input.primaryLanguage !== undefined)
updateData.primaryLanguage = input.primaryLanguage;
if (input.fallbackLanguage !== undefined)
updateData.fallbackLanguage = input.fallbackLanguage;
const [updatedLibrary] = await database
.update(libraryFolders)
.set(updateData)
.where(eq(libraryFolders.id, id))
.returning();
return c.json<LibraryUpdateOutput>(updatedLibrary);
},
)
.delete("/:id", zValidator("param", LibraryDeleteInput), async (c) => {
const { id } = c.req.valid("param");
const [existingLibrary] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.id, id))
.limit(1);
if (!existingLibrary) {
throw createHTTPException(404, `Library with id ${id} not found.`);
}
await database.delete(libraryFolders).where(eq(libraryFolders.id, id));
return c.json<LibraryDeleteOutput>({
success: true,
message: `Library with id ${id} deleted successfully.`,
});
})
.post("/create", zValidator("json", LibraryCreateInput), async (c) => {
const input = c.req.valid("json");
const existingByName = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.name, input.name))
.limit(1);
if (existingByName.length > 0) {
throw createHTTPException(
409,
`A library with the name "${input.name}" already exists.`,
);
}
const existingByPath = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.path, input.path))
.limit(1);
if (existingByPath.length > 0) {
throw createHTTPException(
409,
`A library with the path "${input.path}" already exists.`,
);
}
const [newLibrary] = await database
.insert(libraryFolders)
.values({
name: input.name,
type: input.type,
path: input.path,
primaryLanguage: input.primaryLanguage,
fallbackLanguage: input.fallbackLanguage,
})
.returning();
return c.json<LibraryCreateOutput>({
success: true,
message: `Library with id ${newLibrary.id} created successfully.`,
data: newLibrary,
});
});