feat(db): mesh data model — meshes, members, invites, audit log

- pgSchema "mesh" with 4 tables isolating the peer mesh domain
- Enums: visibility, transport, tier, role
- audit_log is metadata-only (E2E encryption enforced at broker/client)
- Cascade on mesh delete, soft-delete via archivedAt/revokedAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import { auth } from "@turbostarter/auth/server";
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { HttpException, slugify } from "@turbostarter/shared/utils";
const MAX_ATTEMPTS = 3;
export const generateSlug = async (name: string) => {
const base = slugify(name, {
lower: true,
remove: /[.,'+:()]/g,
});
let slug = base;
let isAvailable = false;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
let check;
try {
check = await auth.api.checkOrganizationSlug({
body: { slug },
});
} catch {
check = { status: false };
}
if (check.status) {
isAvailable = true;
break;
}
const randomDigits = Math.floor(100000 + Math.random() * 900000).toString();
slug = `${base}-${randomDigits}`;
}
if (!isAvailable) {
throw new HttpException(HttpStatusCode.BAD_REQUEST, {
code: "organization:error.slugNotAvailable",
});
}
return { slug };
};

View File

@@ -0,0 +1,65 @@
import dayjs from "dayjs";
import {
and,
asc,
between,
count,
eq,
getOrderByFromSort,
ilike,
inArray,
} from "@turbostarter/db";
import { invitation } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import type { GetInvitationsInput } from "../../../schema";
export const getInvitations = async ({
organizationId,
...input
}: GetInvitationsInput & { organizationId: string }) => {
const offset = (input.page - 1) * input.perPage;
const where = and(
input.email ? ilike(invitation.email, `%${input.email}%`) : undefined,
input.role ? inArray(invitation.role, input.role) : undefined,
input.status ? inArray(invitation.status, input.status) : undefined,
input.expiresAt
? between(
invitation.expiresAt,
dayjs(input.expiresAt[0]).startOf("day").toDate(),
dayjs(input.expiresAt[1]).endOf("day").toDate(),
)
: undefined,
eq(invitation.organizationId, organizationId),
);
const orderBy = input.sort
? getOrderByFromSort({ sort: input.sort, defaultSchema: invitation })
: [asc(invitation.email)];
return db.transaction(async (tx) => {
const data = await db
.select()
.from(invitation)
.where(where)
.limit(input.perPage)
.offset(offset)
.orderBy(...orderBy);
const total = await tx
.select({
count: count(),
})
.from(invitation)
.where(where)
.execute()
.then((res) => res[0]?.count ?? 0);
return {
data,
total,
};
});
};

View File

@@ -0,0 +1,102 @@
import dayjs from "dayjs";
import { MemberRole } from "@turbostarter/auth";
import {
and,
asc,
between,
count,
eq,
getOrderByFromSort,
ilike,
inArray,
or,
sql,
} from "@turbostarter/db";
import { member, user } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import type { GetMembersInput } from "../../../schema";
export const getMembers = async ({
organizationId,
...input
}: GetMembersInput & { organizationId: string }) => {
const offset = (input.page - 1) * input.perPage;
const where = and(
input.q
? or(ilike(user.name, `%${input.q}%`), ilike(user.email, `%${input.q}%`))
: undefined,
input.role ? inArray(member.role, input.role) : undefined,
input.createdAt
? between(
member.createdAt,
dayjs(input.createdAt[0]).startOf("day").toDate(),
dayjs(input.createdAt[1]).endOf("day").toDate(),
)
: undefined,
eq(member.organizationId, organizationId),
);
const orderBy = input.sort
? getOrderByFromSort({ sort: input.sort, defaultSchema: member })
: [asc(user.name)];
return db.transaction(async (tx) => {
const data = await tx
.select({
id: member.id,
organizationId: member.organizationId,
role: sql<MemberRole>`${member.role}`,
createdAt: member.createdAt,
userId: member.userId,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
},
})
.from(member)
.leftJoin(user, eq(member.userId, user.id))
.where(where)
.limit(input.perPage)
.offset(offset)
.orderBy(...orderBy);
const total = await tx
.select({
count: count(),
})
.from(member)
.leftJoin(user, eq(member.userId, user.id))
.where(where)
.execute()
.then((res) => res[0]?.count ?? 0);
return {
data,
total,
};
});
};
export const getIsOnlyOwner = async ({
organizationId,
userId,
}: {
organizationId: string;
userId: string;
}) => {
const otherOwners = await db.query.member.findMany({
where: (member, { eq, and, not }) =>
and(
eq(member.organizationId, organizationId),
eq(member.role, MemberRole.OWNER),
not(eq(member.userId, userId)),
),
});
return otherOwners.length === 0;
};

View File

@@ -0,0 +1,8 @@
import { eq } from "@turbostarter/db";
import { organization } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
export const getOrganization = async ({ id }: { id: string }) =>
db.query.organization.findFirst({
where: eq(organization.id, id),
});

View File

@@ -0,0 +1,69 @@
import { Hono } from "hono";
import * as z from "zod";
import { MemberRole } from "@turbostarter/auth";
import { enforceAuth, enforceMembership, validate } from "../../middleware";
import { getInvitationsInputSchema, getMembersInputSchema } from "../../schema";
import { generateSlug } from "./queries/generate-slug";
import { getInvitations } from "./queries/invitations";
import { getIsOnlyOwner, getMembers } from "./queries/members";
import { getOrganization } from "./queries/organizations";
export const organizationRouter = new Hono()
.use(enforceAuth)
.get(
"/slug",
validate(
"query",
z.object({
name: z.string(),
}),
),
async (c) => c.json(await generateSlug(c.req.valid("query").name)),
)
.get("/:id", async (c) =>
c.json({ organization: await getOrganization({ id: c.req.param("id") }) }),
)
.get(
"/:id/members",
validate("query", getMembersInputSchema),
(c, next) =>
enforceMembership({ organizationId: c.req.param("id") })(c, next),
async (c) =>
c.json(
await getMembers({
organizationId: c.req.param("id"),
...c.req.valid("query"),
}),
),
)
.get(
"/:id/members/is-only-owner",
(c, next) =>
enforceMembership({
organizationId: c.req.param("id"),
role: MemberRole.OWNER,
})(c, next),
async (c) =>
c.json({
status: await getIsOnlyOwner({
organizationId: c.req.param("id"),
userId: c.var.user.id,
}),
}),
)
.get(
"/:id/invitations",
validate("query", getInvitationsInputSchema),
(c, next) =>
enforceMembership({ organizationId: c.req.param("id") })(c, next),
async (c) =>
c.json(
await getInvitations({
organizationId: c.req.param("id"),
...c.req.valid("query"),
}),
),
);