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
|
|
@ -17,6 +17,8 @@
|
|||
"@trpc/client": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"effect": "^3.18.4",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"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({
|
||||
healthCheck: publicProcedure.query(() => {
|
||||
return "OK";
|
||||
}),
|
||||
privateData: protectedProcedure.query(({ ctx }) => {
|
||||
return {
|
||||
message: "This is private",
|
||||
user: ctx.session.user,
|
||||
};
|
||||
}),
|
||||
dashboard: dashboardRouter,
|
||||
servers: serversRouter,
|
||||
services: servicesRouter,
|
||||
domains: domainsRouter,
|
||||
notes: notesRouter,
|
||||
});
|
||||
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