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:
@@ -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 };
|
||||
};
|
||||
65
packages/api/src/modules/organization/queries/invitations.ts
Normal file
65
packages/api/src/modules/organization/queries/invitations.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
};
|
||||
102
packages/api/src/modules/organization/queries/members.ts
Normal file
102
packages/api/src/modules/organization/queries/members.ts
Normal 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;
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
69
packages/api/src/modules/organization/router.ts
Normal file
69
packages/api/src/modules/organization/router.ts
Normal 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"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user