api: add protected CRUD routers and DNS check logic
This commit is contained in:
parent
c7edd90a7e
commit
5264cccb6c
11 changed files with 709 additions and 8 deletions
|
|
@ -6,7 +6,7 @@ import { env } from "@minmon/env/server";
|
||||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
|
|
||||||
const app = new Elysia()
|
new Elysia()
|
||||||
.use(
|
.use(
|
||||||
cors({
|
cors({
|
||||||
origin: env.CORS_ORIGIN,
|
origin: env.CORS_ORIGIN,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"effect": "^3.18.4",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
91
packages/api/src/lib/domain-check.ts
Normal file
91
packages/api/src/lib/domain-check.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { createDb, domain } from "@minmon/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { resolve4 } from "node:dns/promises";
|
||||||
|
import { Effect } from "effect";
|
||||||
|
|
||||||
|
export type DomainCheckStatus = "MATCH" | "MISMATCH" | "UNRESOLVED";
|
||||||
|
|
||||||
|
export type DomainCheckResult = {
|
||||||
|
status: DomainCheckStatus;
|
||||||
|
resolvedIps: string[];
|
||||||
|
checkedAt: Date;
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DomainCheckTarget = {
|
||||||
|
name: string;
|
||||||
|
expectedServerIp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveIpv4ARecords(hostname: string) {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: async () => [...new Set(await resolve4(hostname))],
|
||||||
|
catch: (error: unknown) =>
|
||||||
|
error instanceof Error ? error : new Error("Unable to resolve IPv4 A records"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDomainDns(target: DomainCheckTarget) {
|
||||||
|
return Effect.match(resolveIpv4ARecords(target.name), {
|
||||||
|
onSuccess: (resolvedIps: string[]): DomainCheckResult => ({
|
||||||
|
status: resolvedIps.includes(target.expectedServerIp) ? "MATCH" : "MISMATCH",
|
||||||
|
resolvedIps,
|
||||||
|
checkedAt: new Date(),
|
||||||
|
message: null,
|
||||||
|
}),
|
||||||
|
onFailure: (error: Error): DomainCheckResult => ({
|
||||||
|
status: "UNRESOLVED",
|
||||||
|
resolvedIps: [],
|
||||||
|
checkedAt: new Date(),
|
||||||
|
message: error.message,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistDomainCheck(
|
||||||
|
database: ReturnType<typeof createDb>,
|
||||||
|
input: {
|
||||||
|
domainId: string;
|
||||||
|
result: DomainCheckResult;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const [updatedDomain] = await database
|
||||||
|
.update(domain)
|
||||||
|
.set({
|
||||||
|
resolutionStatus: input.result.status,
|
||||||
|
lastResolvedIps: input.result.resolvedIps,
|
||||||
|
lastResolvedIp: input.result.resolvedIps[0] ?? null,
|
||||||
|
lastCheckedAt: input.result.checkedAt,
|
||||||
|
lastCheckMessage: input.result.message,
|
||||||
|
})
|
||||||
|
.where(eq(domain.id, input.domainId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedDomain ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAndPersistDomainCheck(
|
||||||
|
database: ReturnType<typeof createDb>,
|
||||||
|
input: {
|
||||||
|
domainId: string;
|
||||||
|
name: string;
|
||||||
|
expectedServerIp: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
checkDomainDns({
|
||||||
|
name: input.name,
|
||||||
|
expectedServerIp: input.expectedServerIp,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedDomain = await persistDomainCheck(database, {
|
||||||
|
domainId: input.domainId,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
domain: updatedDomain,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
packages/api/src/routers/dashboard.ts
Normal file
75
packages/api/src/routers/dashboard.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { db } from "@minmon/db";
|
||||||
|
import { domain, note, server, service } from "@minmon/db";
|
||||||
|
import { count, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { protectedProcedure, router } from "../index";
|
||||||
|
|
||||||
|
export const dashboardRouter = router({
|
||||||
|
summary: protectedProcedure.query(async () => {
|
||||||
|
const [
|
||||||
|
serverCount,
|
||||||
|
serviceCount,
|
||||||
|
domainCount,
|
||||||
|
inactiveServices,
|
||||||
|
domainStatuses,
|
||||||
|
recentNotes,
|
||||||
|
servicesWithServer,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.select({ count: count() }).from(server),
|
||||||
|
db.select({ count: count() }).from(service),
|
||||||
|
db.select({ count: count() }).from(domain),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(service)
|
||||||
|
.where(inArray(service.status, ["down", "maintenance"])),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
status: domain.resolutionStatus,
|
||||||
|
})
|
||||||
|
.from(domain),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
category: note.category,
|
||||||
|
updatedAt: note.updatedAt,
|
||||||
|
serverId: note.serverId,
|
||||||
|
})
|
||||||
|
.from(note)
|
||||||
|
.orderBy(desc(note.updatedAt))
|
||||||
|
.limit(5),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: service.id,
|
||||||
|
name: service.name,
|
||||||
|
status: service.status,
|
||||||
|
serverName: server.name,
|
||||||
|
})
|
||||||
|
.from(service)
|
||||||
|
.innerJoin(server, eq(service.serverId, server.id))
|
||||||
|
.where(inArray(service.status, ["down", "maintenance"]))
|
||||||
|
.orderBy(desc(service.updatedAt))
|
||||||
|
.limit(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const domainsWithMatchIp = domainStatuses.filter((item) => item.status === "MATCH").length;
|
||||||
|
const domainsWithMismatchIp = domainStatuses.filter((item) => item.status === "MISMATCH").length;
|
||||||
|
const domainsUnresolved = domainStatuses.filter((item) => item.status === "UNRESOLVED").length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totals: {
|
||||||
|
servers: serverCount[0]?.count ?? 0,
|
||||||
|
services: serviceCount[0]?.count ?? 0,
|
||||||
|
domains: domainCount[0]?.count ?? 0,
|
||||||
|
},
|
||||||
|
domainChecks: {
|
||||||
|
match: domainsWithMatchIp,
|
||||||
|
mismatch: domainsWithMismatchIp,
|
||||||
|
unresolved: domainsUnresolved,
|
||||||
|
},
|
||||||
|
inactiveServices: inactiveServices[0]?.count ?? 0,
|
||||||
|
recentNotes,
|
||||||
|
recentServiceIssues: servicesWithServer,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
112
packages/api/src/routers/domains.ts
Normal file
112
packages/api/src/routers/domains.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { db } from "@minmon/db";
|
||||||
|
import { domain, service } from "@minmon/db";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { runAndPersistDomainCheck } from "../lib/domain-check";
|
||||||
|
import { protectedProcedure, router } from "../index";
|
||||||
|
import {
|
||||||
|
byIdSchema,
|
||||||
|
domainCreateSchema,
|
||||||
|
domainListSchema,
|
||||||
|
domainUpdateSchema,
|
||||||
|
} from "./schemas";
|
||||||
|
import {
|
||||||
|
createEntityId,
|
||||||
|
requireDomain,
|
||||||
|
requireService,
|
||||||
|
requireServiceWithServer,
|
||||||
|
stripUndefined,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
export const domainsRouter = router({
|
||||||
|
list: protectedProcedure.input(domainListSchema).query(async ({ input }) => {
|
||||||
|
if (!input?.serviceId) {
|
||||||
|
return db.select().from(domain).orderBy(desc(domain.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireService(input.serviceId);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(domain)
|
||||||
|
.where(eq(domain.serviceId, input.serviceId))
|
||||||
|
.orderBy(desc(domain.updatedAt));
|
||||||
|
}),
|
||||||
|
|
||||||
|
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||||
|
return requireDomain(input.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure.input(domainCreateSchema).mutation(async ({ input }) => {
|
||||||
|
const { service: parentService, server: parentServer } = await requireServiceWithServer(input.serviceId);
|
||||||
|
|
||||||
|
const expectedServerIp = input.expectedServerIp ?? parentServer.primaryIpAddress;
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(domain)
|
||||||
|
.values({
|
||||||
|
id: createEntityId(),
|
||||||
|
serviceId: parentService.id,
|
||||||
|
name: input.name,
|
||||||
|
expectedServerIp,
|
||||||
|
remarks: input.remarks,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure.input(domainUpdateSchema).mutation(async ({ input }) => {
|
||||||
|
const { id, serviceId, ...changes } = input;
|
||||||
|
const existing = await requireDomain(id);
|
||||||
|
|
||||||
|
let nextExpectedServerIp = changes.expectedServerIp;
|
||||||
|
|
||||||
|
if (serviceId && serviceId !== existing.serviceId) {
|
||||||
|
const { server: parentServer } = await requireServiceWithServer(serviceId);
|
||||||
|
if (!nextExpectedServerIp) {
|
||||||
|
nextExpectedServerIp = parentServer.primaryIpAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(domain)
|
||||||
|
.set(stripUndefined({ serviceId, expectedServerIp: nextExpectedServerIp, ...changes }))
|
||||||
|
.where(eq(domain.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||||
|
await requireDomain(input.id);
|
||||||
|
await db.delete(domain).where(eq(domain.id, input.id));
|
||||||
|
return { id: input.id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
check: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||||
|
const existing = await requireDomain(input.id);
|
||||||
|
|
||||||
|
const result = await runAndPersistDomainCheck(db, {
|
||||||
|
domainId: existing.id,
|
||||||
|
name: existing.name,
|
||||||
|
expectedServerIp: existing.expectedServerIp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain: result.domain ?? existing,
|
||||||
|
check: result.result,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
serviceOptions: protectedProcedure.query(async () => {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: service.id,
|
||||||
|
name: service.name,
|
||||||
|
serverId: service.serverId,
|
||||||
|
})
|
||||||
|
.from(service)
|
||||||
|
.orderBy(desc(service.updatedAt));
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import { protectedProcedure, publicProcedure, router } from "../index";
|
import { publicProcedure, router } from "../index";
|
||||||
|
|
||||||
|
import { dashboardRouter } from "./dashboard";
|
||||||
|
import { domainsRouter } from "./domains";
|
||||||
|
import { notesRouter } from "./notes";
|
||||||
|
import { serversRouter } from "./servers";
|
||||||
|
import { servicesRouter } from "./services";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
healthCheck: publicProcedure.query(() => {
|
healthCheck: publicProcedure.query(() => {
|
||||||
return "OK";
|
return "OK";
|
||||||
}),
|
}),
|
||||||
privateData: protectedProcedure.query(({ ctx }) => {
|
dashboard: dashboardRouter,
|
||||||
return {
|
servers: serversRouter,
|
||||||
message: "This is private",
|
services: servicesRouter,
|
||||||
user: ctx.session.user,
|
domains: domainsRouter,
|
||||||
};
|
notes: notesRouter,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
|
||||||
79
packages/api/src/routers/notes.ts
Normal file
79
packages/api/src/routers/notes.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { db } from "@minmon/db";
|
||||||
|
import { note, server } from "@minmon/db";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { protectedProcedure, router } from "../index";
|
||||||
|
import { byIdSchema, noteCreateSchema, noteListSchema, noteUpdateSchema } from "./schemas";
|
||||||
|
import {
|
||||||
|
createEntityId,
|
||||||
|
requireNote,
|
||||||
|
requireServer,
|
||||||
|
stripUndefined,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
export const notesRouter = router({
|
||||||
|
list: protectedProcedure.input(noteListSchema).query(async ({ input }) => {
|
||||||
|
if (!input?.serverId) {
|
||||||
|
return db.select().from(note).orderBy(desc(note.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireServer(input.serverId);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(note)
|
||||||
|
.where(eq(note.serverId, input.serverId))
|
||||||
|
.orderBy(desc(note.updatedAt));
|
||||||
|
}),
|
||||||
|
|
||||||
|
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||||
|
return requireNote(input.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure.input(noteCreateSchema).mutation(async ({ input }) => {
|
||||||
|
await requireServer(input.serverId);
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(note)
|
||||||
|
.values({
|
||||||
|
id: createEntityId(),
|
||||||
|
...input,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure.input(noteUpdateSchema).mutation(async ({ input }) => {
|
||||||
|
const { id, serverId, ...changes } = input;
|
||||||
|
await requireNote(id);
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await requireServer(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(note)
|
||||||
|
.set(stripUndefined({ serverId, ...changes }))
|
||||||
|
.where(eq(note.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||||
|
await requireNote(input.id);
|
||||||
|
await db.delete(note).where(eq(note.id, input.id));
|
||||||
|
return { id: input.id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
serverOptions: protectedProcedure.query(async () => {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
})
|
||||||
|
.from(server)
|
||||||
|
.orderBy(desc(server.updatedAt));
|
||||||
|
}),
|
||||||
|
});
|
||||||
107
packages/api/src/routers/schemas.ts
Normal file
107
packages/api/src/routers/schemas.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const entityIdSchema = z.string().min(1).max(191);
|
||||||
|
|
||||||
|
export const ipv4Schema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(
|
||||||
|
/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/,
|
||||||
|
"Must be a valid IPv4 address",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const optionalTextSchema = z.string().trim().max(5000).nullable().optional();
|
||||||
|
|
||||||
|
export const hostnameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(253)
|
||||||
|
.regex(
|
||||||
|
/^(?=.{1,253}$)(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,63}$/,
|
||||||
|
"Must be a valid domain or subdomain",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const serverStatusSchema = z.enum(["active", "maintenance", "inactive"]);
|
||||||
|
export const serviceTypeSchema = z.enum(["web_app", "mail", "database", "api", "proxy", "other"]);
|
||||||
|
export const serviceProtocolSchema = z.enum(["http", "https", "tcp", "udp", "other"]);
|
||||||
|
export const serviceStatusSchema = z.enum(["active", "down", "maintenance"]);
|
||||||
|
export const domainCheckStatusSchema = z.enum(["MATCH", "MISMATCH", "UNRESOLVED"]);
|
||||||
|
|
||||||
|
export const byIdSchema = z.object({
|
||||||
|
id: entityIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serverCreateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
primaryIpAddress: ipv4Schema,
|
||||||
|
secondaryIpAddress: ipv4Schema.nullable().optional(),
|
||||||
|
operatingSystem: z.string().trim().max(120).nullable().optional(),
|
||||||
|
location: z.string().trim().max(120).nullable().optional(),
|
||||||
|
provider: z.string().trim().max(120).nullable().optional(),
|
||||||
|
description: optionalTextSchema,
|
||||||
|
status: serverStatusSchema.default("active"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serverUpdateSchema = serverCreateSchema.partial().extend({
|
||||||
|
id: entityIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serviceListSchema = z
|
||||||
|
.object({
|
||||||
|
serverId: entityIdSchema.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export const serviceCreateSchema = z.object({
|
||||||
|
serverId: entityIdSchema,
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
type: serviceTypeSchema.default("other"),
|
||||||
|
internalPort: z.coerce.number().int().min(1).max(65535).nullable().optional(),
|
||||||
|
externalPort: z.coerce.number().int().min(1).max(65535).nullable().optional(),
|
||||||
|
protocol: serviceProtocolSchema.default("http"),
|
||||||
|
status: serviceStatusSchema.default("active"),
|
||||||
|
description: optionalTextSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serviceUpdateSchema = serviceCreateSchema.partial().extend({
|
||||||
|
id: entityIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const domainListSchema = z
|
||||||
|
.object({
|
||||||
|
serviceId: entityIdSchema.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export const domainCreateSchema = z.object({
|
||||||
|
serviceId: entityIdSchema,
|
||||||
|
name: hostnameSchema,
|
||||||
|
expectedServerIp: ipv4Schema.optional(),
|
||||||
|
remarks: optionalTextSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const domainUpdateSchema = z.object({
|
||||||
|
id: entityIdSchema,
|
||||||
|
serviceId: entityIdSchema.optional(),
|
||||||
|
name: hostnameSchema.optional(),
|
||||||
|
expectedServerIp: ipv4Schema.optional(),
|
||||||
|
remarks: optionalTextSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const noteListSchema = z
|
||||||
|
.object({
|
||||||
|
serverId: entityIdSchema.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export const noteCreateSchema = z.object({
|
||||||
|
serverId: entityIdSchema,
|
||||||
|
title: z.string().trim().min(1).max(160),
|
||||||
|
content: z.string().trim().min(1).max(10000),
|
||||||
|
category: z.string().trim().max(80).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const noteUpdateSchema = noteCreateSchema.partial().extend({
|
||||||
|
id: entityIdSchema,
|
||||||
|
});
|
||||||
61
packages/api/src/routers/servers.ts
Normal file
61
packages/api/src/routers/servers.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { db } from "@minmon/db";
|
||||||
|
import { note, server, service } from "@minmon/db";
|
||||||
|
import { count, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { protectedProcedure, router } from "../index";
|
||||||
|
import { byIdSchema, serverCreateSchema, serverUpdateSchema } from "./schemas";
|
||||||
|
import { createEntityId, requireServer, stripUndefined } from "./utils";
|
||||||
|
|
||||||
|
export const serversRouter = router({
|
||||||
|
list: protectedProcedure.query(async () => {
|
||||||
|
return db.select().from(server).orderBy(desc(server.updatedAt));
|
||||||
|
}),
|
||||||
|
|
||||||
|
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||||
|
const item = await requireServer(input.id);
|
||||||
|
|
||||||
|
const [serviceCount, noteCount] = await Promise.all([
|
||||||
|
db.select({ count: count() }).from(service).where(eq(service.serverId, input.id)),
|
||||||
|
db.select({ count: count() }).from(note).where(eq(note.serverId, input.id)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
summary: {
|
||||||
|
services: serviceCount[0]?.count ?? 0,
|
||||||
|
notes: noteCount[0]?.count ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure.input(serverCreateSchema).mutation(async ({ input }) => {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(server)
|
||||||
|
.values({
|
||||||
|
id: createEntityId(),
|
||||||
|
...input,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure.input(serverUpdateSchema).mutation(async ({ input }) => {
|
||||||
|
const { id, ...changes } = input;
|
||||||
|
await requireServer(id);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(server)
|
||||||
|
.set(stripUndefined(changes))
|
||||||
|
.where(eq(server.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||||
|
await requireServer(input.id);
|
||||||
|
await db.delete(server).where(eq(server.id, input.id));
|
||||||
|
return { id: input.id };
|
||||||
|
}),
|
||||||
|
});
|
||||||
82
packages/api/src/routers/services.ts
Normal file
82
packages/api/src/routers/services.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { db } from "@minmon/db";
|
||||||
|
import { domain, service } from "@minmon/db";
|
||||||
|
import { count, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { protectedProcedure, router } from "../index";
|
||||||
|
import {
|
||||||
|
byIdSchema,
|
||||||
|
serviceCreateSchema,
|
||||||
|
serviceListSchema,
|
||||||
|
serviceUpdateSchema,
|
||||||
|
} from "./schemas";
|
||||||
|
import {
|
||||||
|
createEntityId,
|
||||||
|
requireServer,
|
||||||
|
requireService,
|
||||||
|
stripUndefined,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
export const servicesRouter = router({
|
||||||
|
list: protectedProcedure.input(serviceListSchema).query(async ({ input }) => {
|
||||||
|
if (!input?.serverId) {
|
||||||
|
return db.select().from(service).orderBy(desc(service.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireServer(input.serverId);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(service)
|
||||||
|
.where(eq(service.serverId, input.serverId))
|
||||||
|
.orderBy(desc(service.updatedAt));
|
||||||
|
}),
|
||||||
|
|
||||||
|
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
||||||
|
const item = await requireService(input.id);
|
||||||
|
const [domainCount] = await db.select({ count: count() }).from(domain).where(eq(domain.serviceId, input.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
summary: {
|
||||||
|
domains: domainCount?.count ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure.input(serviceCreateSchema).mutation(async ({ input }) => {
|
||||||
|
await requireServer(input.serverId);
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(service)
|
||||||
|
.values({
|
||||||
|
id: createEntityId(),
|
||||||
|
...input,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure.input(serviceUpdateSchema).mutation(async ({ input }) => {
|
||||||
|
const { id, serverId, ...changes } = input;
|
||||||
|
await requireService(id);
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await requireServer(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(service)
|
||||||
|
.set(stripUndefined({ serverId, ...changes }))
|
||||||
|
.where(eq(service.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
||||||
|
await requireService(input.id);
|
||||||
|
await db.delete(service).where(eq(service.id, input.id));
|
||||||
|
return { id: input.id };
|
||||||
|
}),
|
||||||
|
});
|
||||||
87
packages/api/src/routers/utils.ts
Normal file
87
packages/api/src/routers/utils.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { db } from "@minmon/db";
|
||||||
|
import { domain, note, server, service } from "@minmon/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export function createEntityId() {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripUndefined<T extends Record<string, unknown>>(values: T) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(values).filter(([, value]) => value !== undefined),
|
||||||
|
) as Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notFound(message: string): never {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badRequest(message: string): never {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireServer(serverId: string) {
|
||||||
|
const [item] = await db.select().from(server).where(eq(server.id, serverId)).limit(1);
|
||||||
|
if (!item) {
|
||||||
|
notFound("Server not found");
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireService(serviceId: string) {
|
||||||
|
const [item] = await db.select().from(service).where(eq(service.id, serviceId)).limit(1);
|
||||||
|
if (!item) {
|
||||||
|
notFound("Service not found");
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireDomain(domainId: string) {
|
||||||
|
const [item] = await db.select().from(domain).where(eq(domain.id, domainId)).limit(1);
|
||||||
|
if (!item) {
|
||||||
|
notFound("Domain not found");
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireNote(noteId: string) {
|
||||||
|
const [item] = await db.select().from(note).where(eq(note.id, noteId)).limit(1);
|
||||||
|
if (!item) {
|
||||||
|
notFound("Note not found");
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireServiceWithServer(serviceId: string) {
|
||||||
|
const item = await requireService(serviceId);
|
||||||
|
const parentServer = await requireServer(item.serverId);
|
||||||
|
return { service: item, server: parentServer };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireDomainWithRelations(domainId: string) {
|
||||||
|
const item = await requireDomain(domainId);
|
||||||
|
const parentService = await requireService(item.serviceId);
|
||||||
|
const parentServer = await requireServer(parentService.serverId);
|
||||||
|
return { domain: item, service: parentService, server: parentServer };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureServiceBelongsToServer(serviceId: string, serverId: string) {
|
||||||
|
const [item] = await db
|
||||||
|
.select()
|
||||||
|
.from(service)
|
||||||
|
.where(and(eq(service.id, serviceId), eq(service.serverId, serverId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
badRequest("Service does not belong to the selected server");
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue