diff --git a/packages/api/src/modules/admin/audit/queries.ts b/packages/api/src/modules/admin/audit/queries.ts new file mode 100644 index 0000000..eebf776 --- /dev/null +++ b/packages/api/src/modules/admin/audit/queries.ts @@ -0,0 +1,89 @@ +import dayjs from "dayjs"; + +import { + and, + between, + count, + desc, + eq, + getOrderByFromSort, + gte, + ilike, + inArray, + or, + sql, +} from "@turbostarter/db"; +import { auditLog, mesh } from "@turbostarter/db/schema"; +import { db } from "@turbostarter/db/server"; + +import type { GetAuditInput } from "../../../schema"; + +export const getMessages24hCount = async () => + db + .select({ count: count() }) + .from(auditLog) + .where( + and( + eq(auditLog.eventType, "message_sent"), + gte(auditLog.createdAt, dayjs().subtract(24, "hour").toDate()), + ), + ) + .then((res) => res[0]?.count ?? 0); + +export const getAudit = async (input: GetAuditInput) => { + const offset = (input.page - 1) * input.perPage; + + const where = and( + input.q + ? or( + ilike(auditLog.eventType, `%${input.q}%`), + ilike(mesh.name, `%${input.q}%`), + ilike(auditLog.actorPeerId, `%${input.q}%`), + ) + : undefined, + input.eventType ? inArray(auditLog.eventType, input.eventType) : undefined, + input.meshId ? inArray(auditLog.meshId, input.meshId) : undefined, + input.createdAt + ? between( + auditLog.createdAt, + dayjs(input.createdAt[0]).startOf("day").toDate(), + dayjs(input.createdAt[1]).endOf("day").toDate(), + ) + : undefined, + ); + + const orderBy = input.sort + ? getOrderByFromSort({ sort: input.sort, defaultSchema: auditLog }) + : [desc(auditLog.createdAt)]; + + return db.transaction(async (tx) => { + const data = await tx + .select({ + id: auditLog.id, + meshId: auditLog.meshId, + meshName: mesh.name, + meshSlug: mesh.slug, + eventType: auditLog.eventType, + actorPeerId: auditLog.actorPeerId, + targetPeerId: auditLog.targetPeerId, + metadata: sql>`${auditLog.metadata}`, + createdAt: auditLog.createdAt, + }) + .from(auditLog) + .leftJoin(mesh, eq(auditLog.meshId, mesh.id)) + .where(where) + .limit(input.perPage) + .offset(offset) + .orderBy(...orderBy); + + const total = await tx + .select({ count: count() }) + .from(auditLog) + .leftJoin(mesh, eq(auditLog.meshId, mesh.id)) + .where(where) + .execute() + .then((res) => res[0]?.count ?? 0); + + return { data, total }; + }); +}; diff --git a/packages/api/src/modules/admin/audit/router.ts b/packages/api/src/modules/admin/audit/router.ts new file mode 100644 index 0000000..2aea900 --- /dev/null +++ b/packages/api/src/modules/admin/audit/router.ts @@ -0,0 +1,12 @@ +import { Hono } from "hono"; + +import { validate } from "../../../middleware"; +import { getAuditInputSchema } from "../../../schema"; + +import { getAudit } from "./queries"; + +export const auditRouter = new Hono().get( + "/", + validate("query", getAuditInputSchema), + async (c) => c.json(await getAudit(c.req.valid("query"))), +); diff --git a/packages/api/src/modules/admin/invites/queries.ts b/packages/api/src/modules/admin/invites/queries.ts new file mode 100644 index 0000000..1ccc0fd --- /dev/null +++ b/packages/api/src/modules/admin/invites/queries.ts @@ -0,0 +1,73 @@ +import { + and, + count, + desc, + eq, + getOrderByFromSort, + ilike, + isNotNull, + isNull, + lt, + or, +} from "@turbostarter/db"; +import { user } from "@turbostarter/db/schema"; +import { invite, mesh } from "@turbostarter/db/schema"; +import { db } from "@turbostarter/db/server"; + +import type { GetInvitesInput } from "../../../schema"; + +export const getInvites = async (input: GetInvitesInput) => { + const offset = (input.page - 1) * input.perPage; + const now = new Date(); + + const where = and( + input.q + ? or( + ilike(mesh.name, `%${input.q}%`), + ilike(invite.token, `%${input.q}%`), + ) + : undefined, + input.revoked === true ? isNotNull(invite.revokedAt) : undefined, + input.revoked === false ? isNull(invite.revokedAt) : undefined, + input.expired === true ? lt(invite.expiresAt, now) : undefined, + ); + + const orderBy = input.sort + ? getOrderByFromSort({ sort: input.sort, defaultSchema: invite }) + : [desc(invite.createdAt)]; + + return db.transaction(async (tx) => { + const data = await tx + .select({ + id: invite.id, + meshId: invite.meshId, + meshName: mesh.name, + meshSlug: mesh.slug, + token: invite.token, + maxUses: invite.maxUses, + usedCount: invite.usedCount, + role: invite.role, + expiresAt: invite.expiresAt, + createdAt: invite.createdAt, + revokedAt: invite.revokedAt, + createdByName: user.name, + }) + .from(invite) + .leftJoin(mesh, eq(invite.meshId, mesh.id)) + .leftJoin(user, eq(invite.createdBy, user.id)) + .where(where) + .limit(input.perPage) + .offset(offset) + .orderBy(...orderBy); + + const total = await tx + .select({ count: count() }) + .from(invite) + .leftJoin(mesh, eq(invite.meshId, mesh.id)) + .where(where) + .execute() + .then((res) => res[0]?.count ?? 0); + + return { data, total }; + }); +}; diff --git a/packages/api/src/modules/admin/invites/router.ts b/packages/api/src/modules/admin/invites/router.ts new file mode 100644 index 0000000..62e7c21 --- /dev/null +++ b/packages/api/src/modules/admin/invites/router.ts @@ -0,0 +1,12 @@ +import { Hono } from "hono"; + +import { validate } from "../../../middleware"; +import { getInvitesInputSchema } from "../../../schema"; + +import { getInvites } from "./queries"; + +export const invitesRouter = new Hono().get( + "/", + validate("query", getInvitesInputSchema), + async (c) => c.json(await getInvites(c.req.valid("query"))), +); diff --git a/packages/api/src/modules/admin/meshes/queries.ts b/packages/api/src/modules/admin/meshes/queries.ts new file mode 100644 index 0000000..897714a --- /dev/null +++ b/packages/api/src/modules/admin/meshes/queries.ts @@ -0,0 +1,195 @@ +import dayjs from "dayjs"; + +import { + and, + asc, + between, + count, + desc, + eq, + getOrderByFromSort, + ilike, + inArray, + isNull, + isNotNull, + or, + sql, +} from "@turbostarter/db"; +import { user } from "@turbostarter/db/schema"; +import { + auditLog, + invite, + mesh, + meshMember, + presence, +} from "@turbostarter/db/schema"; +import { db } from "@turbostarter/db/server"; + +import type { GetMeshesInput } from "../../../schema"; + +export const getMeshesCount = async () => + db + .select({ count: count() }) + .from(mesh) + .then((res) => res[0]?.count ?? 0); + +export const getActiveMeshesCount = async () => + db + .select({ count: count() }) + .from(mesh) + .where(isNull(mesh.archivedAt)) + .then((res) => res[0]?.count ?? 0); + +export const getMeshes = async (input: GetMeshesInput) => { + const offset = (input.page - 1) * input.perPage; + + const where = and( + input.q + ? or(ilike(mesh.name, `%${input.q}%`), ilike(mesh.slug, `%${input.q}%`)) + : undefined, + input.tier ? inArray(mesh.tier, input.tier) : undefined, + input.transport ? inArray(mesh.transport, input.transport) : undefined, + input.visibility ? inArray(mesh.visibility, input.visibility) : undefined, + input.archived === true ? isNotNull(mesh.archivedAt) : undefined, + input.archived === false ? isNull(mesh.archivedAt) : undefined, + input.createdAt + ? between( + mesh.createdAt, + dayjs(input.createdAt[0]).startOf("day").toDate(), + dayjs(input.createdAt[1]).endOf("day").toDate(), + ) + : undefined, + ); + + const orderBy = input.sort + ? getOrderByFromSort({ sort: input.sort, defaultSchema: mesh }) + : [desc(mesh.createdAt)]; + + return db.transaction(async (tx) => { + const data = await tx + .select({ + id: mesh.id, + name: mesh.name, + slug: mesh.slug, + visibility: mesh.visibility, + transport: mesh.transport, + tier: mesh.tier, + maxPeers: mesh.maxPeers, + createdAt: mesh.createdAt, + archivedAt: mesh.archivedAt, + ownerUserId: mesh.ownerUserId, + ownerName: user.name, + ownerEmail: user.email, + memberCount: sql`( + SELECT COUNT(*)::int FROM mesh.member m WHERE m.mesh_id = ${mesh.id} + )`, + }) + .from(mesh) + .leftJoin(user, eq(mesh.ownerUserId, user.id)) + .where(where) + .limit(input.perPage) + .offset(offset) + .orderBy(...orderBy); + + const total = await tx + .select({ count: count() }) + .from(mesh) + .where(where) + .execute() + .then((res) => res[0]?.count ?? 0); + + return { data, total }; + }); +}; + +export const getMeshById = async (id: string) => { + const [m] = await db + .select({ + id: mesh.id, + name: mesh.name, + slug: mesh.slug, + visibility: mesh.visibility, + transport: mesh.transport, + tier: mesh.tier, + maxPeers: mesh.maxPeers, + createdAt: mesh.createdAt, + archivedAt: mesh.archivedAt, + ownerUserId: mesh.ownerUserId, + ownerName: user.name, + ownerEmail: user.email, + }) + .from(mesh) + .leftJoin(user, eq(mesh.ownerUserId, user.id)) + .where(eq(mesh.id, id)) + .limit(1); + + if (!m) return null; + + const members = await db + .select({ + id: meshMember.id, + displayName: meshMember.displayName, + peerPubkey: meshMember.peerPubkey, + role: meshMember.role, + joinedAt: meshMember.joinedAt, + lastSeenAt: meshMember.lastSeenAt, + revokedAt: meshMember.revokedAt, + userId: meshMember.userId, + }) + .from(meshMember) + .where(eq(meshMember.meshId, id)) + .orderBy(asc(meshMember.joinedAt)); + + const presences = await db + .select({ + id: presence.id, + memberId: presence.memberId, + displayName: meshMember.displayName, + sessionId: presence.sessionId, + pid: presence.pid, + cwd: presence.cwd, + status: presence.status, + statusSource: presence.statusSource, + statusUpdatedAt: presence.statusUpdatedAt, + connectedAt: presence.connectedAt, + lastPingAt: presence.lastPingAt, + disconnectedAt: presence.disconnectedAt, + }) + .from(presence) + .leftJoin(meshMember, eq(presence.memberId, meshMember.id)) + .where(eq(meshMember.meshId, id)) + .orderBy(desc(presence.connectedAt)) + .limit(50); + + const invites = await db + .select({ + id: invite.id, + token: invite.token, + maxUses: invite.maxUses, + usedCount: invite.usedCount, + role: invite.role, + expiresAt: invite.expiresAt, + createdAt: invite.createdAt, + revokedAt: invite.revokedAt, + }) + .from(invite) + .where(eq(invite.meshId, id)) + .orderBy(desc(invite.createdAt)) + .limit(50); + + const auditEvents = await db + .select({ + id: auditLog.id, + eventType: auditLog.eventType, + actorPeerId: auditLog.actorPeerId, + targetPeerId: auditLog.targetPeerId, + metadata: auditLog.metadata, + createdAt: auditLog.createdAt, + }) + .from(auditLog) + .where(eq(auditLog.meshId, id)) + .orderBy(desc(auditLog.createdAt)) + .limit(50); + + return { mesh: m, members, presences, invites, auditEvents }; +}; diff --git a/packages/api/src/modules/admin/meshes/router.ts b/packages/api/src/modules/admin/meshes/router.ts new file mode 100644 index 0000000..3bc2f7a --- /dev/null +++ b/packages/api/src/modules/admin/meshes/router.ts @@ -0,0 +1,22 @@ +import { Hono } from "hono"; + +import { validate } from "../../../middleware"; +import { getMeshesInputSchema } from "../../../schema"; + +import { getMeshById, getMeshes } from "./queries"; + +export const meshesRouter = new Hono() + .get("/", validate("query", getMeshesInputSchema), async (c) => + c.json(await getMeshes(c.req.valid("query"))), + ) + .get("/:id", async (c) => + c.json( + (await getMeshById(c.req.param("id"))) ?? { + mesh: null, + members: [], + presences: [], + invites: [], + auditEvents: [], + }, + ), + ); diff --git a/packages/api/src/modules/admin/router.ts b/packages/api/src/modules/admin/router.ts index 70288e9..4dfa493 100644 --- a/packages/api/src/modules/admin/router.ts +++ b/packages/api/src/modules/admin/router.ts @@ -2,10 +2,23 @@ import { Hono } from "hono"; import { enforceAdmin, enforceAuth } from "../../middleware"; +import { auditRouter } from "./audit/router"; +import { getMessages24hCount } from "./audit/queries"; import { getCustomersCount } from "./customers/queries"; import { customersRouter } from "./customers/router"; +import { invitesRouter } from "./invites/router"; +import { + getActiveMeshesCount, + getMeshesCount, +} from "./meshes/queries"; +import { meshesRouter } from "./meshes/router"; import { getOrganizationsCount } from "./organizations/queries"; import { organizationsRouter } from "./organizations/router"; +import { + getActivePresencesCount, + getPresencesCount, +} from "./sessions/queries"; +import { sessionsRouter } from "./sessions/router"; import { getUsersCount } from "./users/queries"; import { usersRouter } from "./users/router"; @@ -15,6 +28,10 @@ export const adminRouter = new Hono() .route("/users", usersRouter) .route("/organizations", organizationsRouter) .route("/customers", customersRouter) + .route("/meshes", meshesRouter) + .route("/sessions", sessionsRouter) + .route("/invites", invitesRouter) + .route("/audit", auditRouter) .get("/summary", async (c) => { const [users, organizations, customers] = await Promise.all([ getUsersCount(), @@ -23,4 +40,22 @@ export const adminRouter = new Hono() ]); return c.json({ users, organizations, customers }); + }) + .get("/summary/mesh", async (c) => { + const [meshes, activeMeshes, totalPresences, activePresences, messages24h] = + await Promise.all([ + getMeshesCount(), + getActiveMeshesCount(), + getPresencesCount(), + getActivePresencesCount(), + getMessages24hCount(), + ]); + + return c.json({ + meshes, + activeMeshes, + totalPresences, + activePresences, + messages24h, + }); }); diff --git a/packages/api/src/modules/admin/sessions/queries.ts b/packages/api/src/modules/admin/sessions/queries.ts new file mode 100644 index 0000000..f9bf346 --- /dev/null +++ b/packages/api/src/modules/admin/sessions/queries.ts @@ -0,0 +1,91 @@ +import { + and, + count, + desc, + eq, + getOrderByFromSort, + ilike, + inArray, + isNull, + or, + sql, +} from "@turbostarter/db"; +import { mesh, meshMember, presence } from "@turbostarter/db/schema"; +import { db } from "@turbostarter/db/server"; + +import type { GetSessionsInput } from "../../../schema"; + +export const getPresencesCount = async () => + db + .select({ count: count() }) + .from(presence) + .then((res) => res[0]?.count ?? 0); + +export const getActivePresencesCount = async () => + db + .select({ count: count() }) + .from(presence) + .where(isNull(presence.disconnectedAt)) + .then((res) => res[0]?.count ?? 0); + +export const getSessions = async (input: GetSessionsInput) => { + const offset = (input.page - 1) * input.perPage; + + const where = and( + input.q + ? or( + ilike(meshMember.displayName, `%${input.q}%`), + ilike(presence.cwd, `%${input.q}%`), + ilike(mesh.name, `%${input.q}%`), + ) + : undefined, + input.status ? inArray(presence.status, input.status) : undefined, + input.active === true ? isNull(presence.disconnectedAt) : undefined, + input.active === false + ? sql`${presence.disconnectedAt} IS NOT NULL` + : undefined, + ); + + const orderBy = input.sort + ? getOrderByFromSort({ sort: input.sort, defaultSchema: presence }) + : [desc(presence.lastPingAt)]; + + return db.transaction(async (tx) => { + const data = await tx + .select({ + id: presence.id, + memberId: presence.memberId, + displayName: meshMember.displayName, + meshId: meshMember.meshId, + meshName: mesh.name, + meshSlug: mesh.slug, + sessionId: presence.sessionId, + pid: presence.pid, + cwd: presence.cwd, + status: presence.status, + statusSource: presence.statusSource, + statusUpdatedAt: presence.statusUpdatedAt, + connectedAt: presence.connectedAt, + lastPingAt: presence.lastPingAt, + disconnectedAt: presence.disconnectedAt, + }) + .from(presence) + .leftJoin(meshMember, eq(presence.memberId, meshMember.id)) + .leftJoin(mesh, eq(meshMember.meshId, mesh.id)) + .where(where) + .limit(input.perPage) + .offset(offset) + .orderBy(...orderBy); + + const total = await tx + .select({ count: count() }) + .from(presence) + .leftJoin(meshMember, eq(presence.memberId, meshMember.id)) + .leftJoin(mesh, eq(meshMember.meshId, mesh.id)) + .where(where) + .execute() + .then((res) => res[0]?.count ?? 0); + + return { data, total }; + }); +}; diff --git a/packages/api/src/modules/admin/sessions/router.ts b/packages/api/src/modules/admin/sessions/router.ts new file mode 100644 index 0000000..8f16faa --- /dev/null +++ b/packages/api/src/modules/admin/sessions/router.ts @@ -0,0 +1,12 @@ +import { Hono } from "hono"; + +import { validate } from "../../../middleware"; +import { getSessionsInputSchema } from "../../../schema"; + +import { getSessions } from "./queries"; + +export const sessionsRouter = new Hono().get( + "/", + validate("query", getSessionsInputSchema), + async (c) => c.json(await getSessions(c.req.valid("query"))), +); diff --git a/packages/api/src/schema/index.ts b/packages/api/src/schema/index.ts index 15d4222..4614655 100644 --- a/packages/api/src/schema/index.ts +++ b/packages/api/src/schema/index.ts @@ -1,2 +1,3 @@ export * from "./admin"; +export * from "./mesh-admin"; export * from "./organization"; diff --git a/packages/api/src/schema/mesh-admin.ts b/packages/api/src/schema/mesh-admin.ts new file mode 100644 index 0000000..795435a --- /dev/null +++ b/packages/api/src/schema/mesh-admin.ts @@ -0,0 +1,274 @@ +import * as z from "zod"; + +import { + offsetPaginationSchema, + sortSchema, +} from "@turbostarter/shared/schema"; + +// --------------------------------------------------------------------- +// Meshes +// --------------------------------------------------------------------- + +export const meshTierEnum = z.enum(["free", "pro", "team", "enterprise"]); +export const meshTransportEnum = z.enum([ + "managed", + "tailscale", + "self_hosted", +]); +export const meshVisibilityEnum = z.enum(["private", "public"]); + +export const getMeshesInputSchema = offsetPaginationSchema.extend({ + sort: z + .string() + .transform((val) => + z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))), + ) + .optional(), + q: z.string().optional(), + tier: z + .union([meshTierEnum.transform((v) => [v]), z.array(meshTierEnum)]) + .optional(), + transport: z + .union([ + meshTransportEnum.transform((v) => [v]), + z.array(meshTransportEnum), + ]) + .optional(), + visibility: z + .union([ + meshVisibilityEnum.transform((v) => [v]), + z.array(meshVisibilityEnum), + ]) + .optional(), + archived: z.coerce.boolean().optional(), + createdAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), +}); +export type GetMeshesInput = z.infer; + +export const getMeshesResponseSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + visibility: meshVisibilityEnum, + transport: meshTransportEnum, + tier: meshTierEnum, + maxPeers: z.number().nullable(), + createdAt: z.coerce.date(), + archivedAt: z.coerce.date().nullable(), + ownerUserId: z.string(), + ownerName: z.string().nullable(), + ownerEmail: z.string().nullable(), + memberCount: z.number(), + }), + ), + total: z.number(), +}); +export type GetMeshesResponse = z.infer; + +export const getMeshResponseSchema = z.object({ + mesh: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + visibility: meshVisibilityEnum, + transport: meshTransportEnum, + tier: meshTierEnum, + maxPeers: z.number().nullable(), + createdAt: z.coerce.date(), + archivedAt: z.coerce.date().nullable(), + ownerUserId: z.string(), + ownerName: z.string().nullable(), + ownerEmail: z.string().nullable(), + }) + .nullable(), + members: z.array( + z.object({ + id: z.string(), + displayName: z.string(), + peerPubkey: z.string(), + role: z.enum(["admin", "member"]), + joinedAt: z.coerce.date(), + lastSeenAt: z.coerce.date().nullable(), + revokedAt: z.coerce.date().nullable(), + userId: z.string().nullable(), + }), + ), + presences: z.array( + z.object({ + id: z.string(), + memberId: z.string(), + displayName: z.string().nullable(), + sessionId: z.string(), + pid: z.number(), + cwd: z.string(), + status: z.enum(["idle", "working", "dnd"]), + statusSource: z.enum(["hook", "manual", "jsonl"]), + statusUpdatedAt: z.coerce.date(), + connectedAt: z.coerce.date(), + lastPingAt: z.coerce.date(), + disconnectedAt: z.coerce.date().nullable(), + }), + ), + invites: z.array( + z.object({ + id: z.string(), + token: z.string(), + maxUses: z.number(), + usedCount: z.number(), + role: z.enum(["admin", "member"]), + expiresAt: z.coerce.date(), + createdAt: z.coerce.date(), + revokedAt: z.coerce.date().nullable(), + }), + ), + auditEvents: z.array( + z.object({ + id: z.string(), + eventType: z.string(), + actorPeerId: z.string().nullable(), + targetPeerId: z.string().nullable(), + metadata: z.record(z.string(), z.any()), + createdAt: z.coerce.date(), + }), + ), +}); +export type GetMeshResponse = z.infer; + +// --------------------------------------------------------------------- +// Sessions (live presences across all meshes) +// --------------------------------------------------------------------- + +export const presenceStatusEnum = z.enum(["idle", "working", "dnd"]); + +export const getSessionsInputSchema = offsetPaginationSchema.extend({ + sort: z + .string() + .transform((val) => + z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))), + ) + .optional(), + q: z.string().optional(), + status: z + .union([presenceStatusEnum.transform((v) => [v]), z.array(presenceStatusEnum)]) + .optional(), + active: z.coerce.boolean().optional(), +}); +export type GetSessionsInput = z.infer; + +export const getSessionsResponseSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + memberId: z.string(), + displayName: z.string().nullable(), + meshId: z.string(), + meshName: z.string().nullable(), + meshSlug: z.string().nullable(), + sessionId: z.string(), + pid: z.number(), + cwd: z.string(), + status: presenceStatusEnum, + statusSource: z.enum(["hook", "manual", "jsonl"]), + statusUpdatedAt: z.coerce.date(), + connectedAt: z.coerce.date(), + lastPingAt: z.coerce.date(), + disconnectedAt: z.coerce.date().nullable(), + }), + ), + total: z.number(), +}); +export type GetSessionsResponse = z.infer; + +// --------------------------------------------------------------------- +// Invites +// --------------------------------------------------------------------- + +export const getInvitesInputSchema = offsetPaginationSchema.extend({ + sort: z + .string() + .transform((val) => + z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))), + ) + .optional(), + q: z.string().optional(), + revoked: z.coerce.boolean().optional(), + expired: z.coerce.boolean().optional(), +}); +export type GetInvitesInput = z.infer; + +export const getInvitesResponseSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + meshId: z.string(), + meshName: z.string().nullable(), + meshSlug: z.string().nullable(), + token: z.string(), + maxUses: z.number(), + usedCount: z.number(), + role: z.enum(["admin", "member"]), + expiresAt: z.coerce.date(), + createdAt: z.coerce.date(), + revokedAt: z.coerce.date().nullable(), + createdByName: z.string().nullable(), + }), + ), + total: z.number(), +}); +export type GetInvitesResponse = z.infer; + +// --------------------------------------------------------------------- +// Audit log +// --------------------------------------------------------------------- + +export const getAuditInputSchema = offsetPaginationSchema.extend({ + sort: z + .string() + .transform((val) => + z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))), + ) + .optional(), + q: z.string().optional(), + eventType: z + .union([z.string().transform((v) => [v]), z.array(z.string())]) + .optional(), + meshId: z + .union([z.string().transform((v) => [v]), z.array(z.string())]) + .optional(), + createdAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(), +}); +export type GetAuditInput = z.infer; + +export const getAuditResponseSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + meshId: z.string(), + meshName: z.string().nullable(), + meshSlug: z.string().nullable(), + eventType: z.string(), + actorPeerId: z.string().nullable(), + targetPeerId: z.string().nullable(), + metadata: z.record(z.string(), z.any()), + createdAt: z.coerce.date(), + }), + ), + total: z.number(), +}); +export type GetAuditResponse = z.infer; + +// --------------------------------------------------------------------- +// Summary counts +// --------------------------------------------------------------------- + +export const getMeshSummaryResponseSchema = z.object({ + meshes: z.number(), + activeMeshes: z.number(), + totalPresences: z.number(), + activePresences: z.number(), + messages24h: z.number(), +}); +export type GetMeshSummaryResponse = z.infer;