feat(api): admin backoffice router — meshes, sessions, invites, audit
Extends the Hono adminRouter with four new read-only mesh admin modules:
meshes, sessions, invites, audit. Each ships {queries,router}.ts following
the existing users/organizations/customers pattern (paginated Drizzle
transactions, getOrderByFromSort sorting, ilike search, enum filters).
- GET /admin/meshes — paginated list with owner join + member count subquery
- GET /admin/meshes/:id — detail: members, presences, invites, last 50 audit
events (returns {mesh: null,...} shell on not-found to stay single-shape
for Hono RPC inference)
- GET /admin/sessions — live WS presences across every mesh, joined to
member/mesh for display, status + active/disconnected filters
- GET /admin/invites — invite tokens w/ mesh + createdBy user joins,
revoked/expired filters
- GET /admin/audit — mesh audit log with eventType/meshId/date filters
Summary endpoint extended: new GET /admin/summary/mesh returns
{meshes, activeMeshes, totalPresences, activePresences, messages24h}.
Messages24h derived from audit_log where event_type='message_sent'
in the past 24h.
Schemas live in packages/api/src/schema/mesh-admin.ts, re-exported from
the schema barrel. All mesh/role/transport enums mirror the DB enums
from packages/db/src/schema/mesh.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
packages/api/src/modules/admin/audit/queries.ts
Normal file
89
packages/api/src/modules/admin/audit/queries.ts
Normal file
@@ -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<Record<string, unknown>>`${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 };
|
||||||
|
});
|
||||||
|
};
|
||||||
12
packages/api/src/modules/admin/audit/router.ts
Normal file
12
packages/api/src/modules/admin/audit/router.ts
Normal file
@@ -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"))),
|
||||||
|
);
|
||||||
73
packages/api/src/modules/admin/invites/queries.ts
Normal file
73
packages/api/src/modules/admin/invites/queries.ts
Normal file
@@ -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 };
|
||||||
|
});
|
||||||
|
};
|
||||||
12
packages/api/src/modules/admin/invites/router.ts
Normal file
12
packages/api/src/modules/admin/invites/router.ts
Normal file
@@ -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"))),
|
||||||
|
);
|
||||||
195
packages/api/src/modules/admin/meshes/queries.ts
Normal file
195
packages/api/src/modules/admin/meshes/queries.ts
Normal file
@@ -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<number>`(
|
||||||
|
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 };
|
||||||
|
};
|
||||||
22
packages/api/src/modules/admin/meshes/router.ts
Normal file
22
packages/api/src/modules/admin/meshes/router.ts
Normal file
@@ -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: [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -2,10 +2,23 @@ import { Hono } from "hono";
|
|||||||
|
|
||||||
import { enforceAdmin, enforceAuth } from "../../middleware";
|
import { enforceAdmin, enforceAuth } from "../../middleware";
|
||||||
|
|
||||||
|
import { auditRouter } from "./audit/router";
|
||||||
|
import { getMessages24hCount } from "./audit/queries";
|
||||||
import { getCustomersCount } from "./customers/queries";
|
import { getCustomersCount } from "./customers/queries";
|
||||||
import { customersRouter } from "./customers/router";
|
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 { getOrganizationsCount } from "./organizations/queries";
|
||||||
import { organizationsRouter } from "./organizations/router";
|
import { organizationsRouter } from "./organizations/router";
|
||||||
|
import {
|
||||||
|
getActivePresencesCount,
|
||||||
|
getPresencesCount,
|
||||||
|
} from "./sessions/queries";
|
||||||
|
import { sessionsRouter } from "./sessions/router";
|
||||||
import { getUsersCount } from "./users/queries";
|
import { getUsersCount } from "./users/queries";
|
||||||
import { usersRouter } from "./users/router";
|
import { usersRouter } from "./users/router";
|
||||||
|
|
||||||
@@ -15,6 +28,10 @@ export const adminRouter = new Hono()
|
|||||||
.route("/users", usersRouter)
|
.route("/users", usersRouter)
|
||||||
.route("/organizations", organizationsRouter)
|
.route("/organizations", organizationsRouter)
|
||||||
.route("/customers", customersRouter)
|
.route("/customers", customersRouter)
|
||||||
|
.route("/meshes", meshesRouter)
|
||||||
|
.route("/sessions", sessionsRouter)
|
||||||
|
.route("/invites", invitesRouter)
|
||||||
|
.route("/audit", auditRouter)
|
||||||
.get("/summary", async (c) => {
|
.get("/summary", async (c) => {
|
||||||
const [users, organizations, customers] = await Promise.all([
|
const [users, organizations, customers] = await Promise.all([
|
||||||
getUsersCount(),
|
getUsersCount(),
|
||||||
@@ -23,4 +40,22 @@ export const adminRouter = new Hono()
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return c.json({ users, organizations, customers });
|
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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
91
packages/api/src/modules/admin/sessions/queries.ts
Normal file
91
packages/api/src/modules/admin/sessions/queries.ts
Normal file
@@ -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 };
|
||||||
|
});
|
||||||
|
};
|
||||||
12
packages/api/src/modules/admin/sessions/router.ts
Normal file
12
packages/api/src/modules/admin/sessions/router.ts
Normal file
@@ -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"))),
|
||||||
|
);
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./admin";
|
export * from "./admin";
|
||||||
|
export * from "./mesh-admin";
|
||||||
export * from "./organization";
|
export * from "./organization";
|
||||||
|
|||||||
274
packages/api/src/schema/mesh-admin.ts
Normal file
274
packages/api/src/schema/mesh-admin.ts
Normal file
@@ -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<typeof getMeshesInputSchema>;
|
||||||
|
|
||||||
|
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<typeof getMeshesResponseSchema>;
|
||||||
|
|
||||||
|
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<typeof getMeshResponseSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 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<typeof getSessionsInputSchema>;
|
||||||
|
|
||||||
|
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<typeof getSessionsResponseSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 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<typeof getInvitesInputSchema>;
|
||||||
|
|
||||||
|
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<typeof getInvitesResponseSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 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<typeof getAuditInputSchema>;
|
||||||
|
|
||||||
|
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<typeof getAuditResponseSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 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<typeof getMeshSummaryResponseSchema>;
|
||||||
Reference in New Issue
Block a user