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:
98
packages/api/src/modules/admin/customers/mutations.ts
Normal file
98
packages/api/src/modules/admin/customers/mutations.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { eq } from "@turbostarter/db";
|
||||
import { creditTransaction, customer } from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
import { HttpStatusCode } from "@turbostarter/shared/constants";
|
||||
import { generateId, HttpException } from "@turbostarter/shared/utils";
|
||||
|
||||
import type { UpdateCreditsInput } from "../../../schema/admin";
|
||||
import type { UpdateCustomer } from "@turbostarter/db/schema";
|
||||
|
||||
export const deleteCustomer = async ({ id }: { id: string }) =>
|
||||
db.delete(customer).where(eq(customer.id, id));
|
||||
|
||||
export const updateCustomer = async ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: UpdateCustomer;
|
||||
}) => db.update(customer).set(data).where(eq(customer.id, id));
|
||||
|
||||
/**
|
||||
* Update customer credits with full transaction audit logging.
|
||||
*/
|
||||
export const updateCustomerCredits = async (
|
||||
customerId: string,
|
||||
input: UpdateCreditsInput,
|
||||
adminUserId: string,
|
||||
) => {
|
||||
return db.transaction(async (tx) => {
|
||||
// Get current customer
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(customer)
|
||||
.where(eq(customer.id, customerId));
|
||||
|
||||
if (!current) {
|
||||
throw new HttpException(HttpStatusCode.NOT_FOUND, {
|
||||
code: "error.customerNotFound",
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate new balance
|
||||
let newBalance: number;
|
||||
let transactionType: "admin_grant" | "admin_deduct";
|
||||
let transactionAmount: number;
|
||||
|
||||
switch (input.action) {
|
||||
case "set":
|
||||
transactionAmount = input.amount - current.credits;
|
||||
transactionType = transactionAmount >= 0 ? "admin_grant" : "admin_deduct";
|
||||
newBalance = input.amount;
|
||||
break;
|
||||
case "add":
|
||||
transactionAmount = input.amount;
|
||||
transactionType = "admin_grant";
|
||||
newBalance = current.credits + input.amount;
|
||||
break;
|
||||
case "deduct":
|
||||
if (current.credits < input.amount) {
|
||||
throw new HttpException(HttpStatusCode.BAD_REQUEST, {
|
||||
code: "error.insufficientCredits",
|
||||
message: `Cannot deduct ${input.amount} credits. Current balance: ${current.credits}`,
|
||||
});
|
||||
}
|
||||
transactionAmount = -input.amount;
|
||||
transactionType = "admin_deduct";
|
||||
newBalance = current.credits - input.amount;
|
||||
break;
|
||||
}
|
||||
|
||||
// Update customer credits
|
||||
await tx
|
||||
.update(customer)
|
||||
.set({
|
||||
credits: newBalance,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(customer.id, customerId));
|
||||
|
||||
// Log transaction
|
||||
await tx.insert(creditTransaction).values({
|
||||
id: generateId(),
|
||||
customerId,
|
||||
amount: transactionAmount,
|
||||
type: transactionType,
|
||||
reason: input.reason ?? `Admin ${input.action}: ${input.amount} credits`,
|
||||
balanceAfter: newBalance,
|
||||
createdBy: adminUserId,
|
||||
});
|
||||
|
||||
return {
|
||||
previousBalance: current.credits,
|
||||
newBalance,
|
||||
action: input.action,
|
||||
amount: input.amount,
|
||||
};
|
||||
});
|
||||
};
|
||||
112
packages/api/src/modules/admin/customers/queries.ts
Normal file
112
packages/api/src/modules/admin/customers/queries.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
between,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
getOrderByFromSort,
|
||||
ilike,
|
||||
inArray,
|
||||
} from "@turbostarter/db";
|
||||
import { creditTransaction, customer, user } from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
|
||||
import type { GetCustomersInput, GetTransactionsInput } from "../../../schema";
|
||||
|
||||
export const getCustomersCount = async () =>
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(customer)
|
||||
.then((res) => res[0]?.count ?? 0);
|
||||
|
||||
export const getCustomers = async (input: GetCustomersInput) => {
|
||||
const offset = (input.page - 1) * input.perPage;
|
||||
|
||||
const where = and(
|
||||
input.q ? ilike(user.name, `%${input.q}%`) : undefined,
|
||||
input.status ? inArray(customer.status, input.status) : undefined,
|
||||
input.plan ? inArray(customer.plan, input.plan) : undefined,
|
||||
input.createdAt
|
||||
? between(
|
||||
customer.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: customer })
|
||||
: [asc(user.name)];
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const data = await db
|
||||
.select({
|
||||
id: customer.id,
|
||||
customerId: customer.customerId,
|
||||
userId: customer.userId,
|
||||
plan: customer.plan,
|
||||
status: customer.status,
|
||||
credits: customer.credits,
|
||||
createdAt: customer.createdAt,
|
||||
updatedAt: customer.updatedAt,
|
||||
user: {
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
},
|
||||
})
|
||||
.from(customer)
|
||||
.leftJoin(user, eq(customer.userId, user.id))
|
||||
.where(where)
|
||||
.limit(input.perPage)
|
||||
.offset(offset)
|
||||
.orderBy(...orderBy);
|
||||
|
||||
const total = await tx
|
||||
.select({ count: count() })
|
||||
.from(customer)
|
||||
.leftJoin(user, eq(customer.userId, user.id))
|
||||
.where(where)
|
||||
.execute()
|
||||
.then((res) => res[0]?.count ?? 0);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get credit transaction history for a customer with pagination.
|
||||
*/
|
||||
export const getCustomerTransactions = async (input: GetTransactionsInput) => {
|
||||
const offset = (input.page - 1) * input.perPage;
|
||||
|
||||
const where = and(
|
||||
eq(creditTransaction.customerId, input.customerId),
|
||||
input.type ? eq(creditTransaction.type, input.type) : undefined,
|
||||
);
|
||||
|
||||
const [data, totalResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(creditTransaction)
|
||||
.where(where)
|
||||
.orderBy(desc(creditTransaction.createdAt))
|
||||
.limit(input.perPage)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(creditTransaction)
|
||||
.where(where),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
total: totalResult[0]?.count ?? 0,
|
||||
};
|
||||
};
|
||||
73
packages/api/src/modules/admin/customers/router.ts
Normal file
73
packages/api/src/modules/admin/customers/router.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
|
||||
import { enforceAdmin, enforceAuth, validate } from "../../../middleware";
|
||||
import {
|
||||
getCustomersInputSchema,
|
||||
getTransactionsSchema,
|
||||
updateCreditsSchema,
|
||||
updateCustomerInputSchema,
|
||||
} from "../../../schema";
|
||||
|
||||
import {
|
||||
deleteCustomer,
|
||||
updateCustomer,
|
||||
updateCustomerCredits,
|
||||
} from "./mutations";
|
||||
import { getCustomerTransactions, getCustomers } from "./queries";
|
||||
|
||||
import type { Session, User } from "@turbostarter/auth";
|
||||
|
||||
interface Variables {
|
||||
user: User;
|
||||
session: Session;
|
||||
}
|
||||
|
||||
export const customersRouter = new Hono<{ Variables: Variables }>()
|
||||
.get("/", validate("query", getCustomersInputSchema), async (c) =>
|
||||
c.json(await getCustomers(c.req.valid("query"))),
|
||||
)
|
||||
.patch("/:id", validate("json", updateCustomerInputSchema), async (c) =>
|
||||
c.json(
|
||||
await updateCustomer({
|
||||
id: c.req.param("id"),
|
||||
data: c.req.valid("json"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.delete("/:id", async (c) =>
|
||||
c.json(await deleteCustomer({ id: c.req.param("id") })),
|
||||
)
|
||||
// Credit management endpoints
|
||||
.patch(
|
||||
"/:id/credits",
|
||||
enforceAuth,
|
||||
enforceAdmin,
|
||||
validate("json", updateCreditsSchema),
|
||||
async (c) => {
|
||||
const customerId = c.req.param("id");
|
||||
const input = c.req.valid("json");
|
||||
const admin = c.var.user;
|
||||
|
||||
const result = await updateCustomerCredits(customerId, input, admin.id);
|
||||
|
||||
return c.json(result);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:id/transactions",
|
||||
enforceAuth,
|
||||
enforceAdmin,
|
||||
validate("query", getTransactionsSchema.omit({ customerId: true })),
|
||||
async (c) => {
|
||||
const customerId = c.req.param("id");
|
||||
const query = c.req.valid("query");
|
||||
|
||||
const result = await getCustomerTransactions({
|
||||
...query,
|
||||
customerId,
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
},
|
||||
);
|
||||
80
packages/api/src/modules/admin/organizations/mutations.ts
Normal file
80
packages/api/src/modules/admin/organizations/mutations.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
import { and, eq } from "@turbostarter/db";
|
||||
import { invitation, member, organization } from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
import { HttpStatusCode } from "@turbostarter/shared/constants";
|
||||
import { HttpException } from "@turbostarter/shared/utils";
|
||||
|
||||
import type {
|
||||
UpdateMemberPayload,
|
||||
UpdateOrganizationPayload,
|
||||
} from "@turbostarter/auth";
|
||||
|
||||
export const deleteOrganization = async ({ id }: { id: string }) =>
|
||||
db.delete(organization).where(eq(organization.id, id));
|
||||
|
||||
export const updateOrganization = async ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: UpdateOrganizationPayload;
|
||||
}) => {
|
||||
if (typeof data.slug === "string") {
|
||||
const current = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, id),
|
||||
columns: { slug: true },
|
||||
});
|
||||
|
||||
if (current?.slug !== data.slug) {
|
||||
let check: { status: boolean };
|
||||
try {
|
||||
check = await auth.api.checkOrganizationSlug({
|
||||
body: { slug: data.slug },
|
||||
});
|
||||
} catch {
|
||||
check = { status: false };
|
||||
}
|
||||
|
||||
if (!check.status) {
|
||||
throw new HttpException(HttpStatusCode.BAD_REQUEST, {
|
||||
code: "auth:error.organization.slugNotAvailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return db.update(organization).set(data).where(eq(organization.id, id));
|
||||
};
|
||||
|
||||
export const deleteOrganizationInvitation = async ({
|
||||
id,
|
||||
organizationId,
|
||||
}: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
}) =>
|
||||
db
|
||||
.delete(invitation)
|
||||
.where(
|
||||
and(eq(invitation.id, id), eq(invitation.organizationId, organizationId)),
|
||||
);
|
||||
|
||||
export const deleteOrganizationMember = async ({
|
||||
id,
|
||||
organizationId,
|
||||
}: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
}) =>
|
||||
db
|
||||
.delete(member)
|
||||
.where(and(eq(member.id, id), eq(member.organizationId, organizationId)));
|
||||
|
||||
export const updateOrganizationMember = async ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: UpdateMemberPayload;
|
||||
}) => db.update(member).set(data).where(eq(member.id, id));
|
||||
108
packages/api/src/modules/admin/organizations/queries.ts
Normal file
108
packages/api/src/modules/admin/organizations/queries.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
between,
|
||||
count,
|
||||
eq,
|
||||
getOrderByFromSort,
|
||||
ilike,
|
||||
sql,
|
||||
} from "@turbostarter/db";
|
||||
import { organization, member } from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
|
||||
import type { GetOrganizationsInput } from "../../../schema";
|
||||
|
||||
export const getOrganizationsCount = async () =>
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(organization)
|
||||
.then((res) => res[0]?.count ?? 0);
|
||||
|
||||
export const getOrganizations = async (input: GetOrganizationsInput) => {
|
||||
const offset = (input.page - 1) * input.perPage;
|
||||
|
||||
const where = and(
|
||||
input.q ? ilike(organization.name, `%${input.q}%`) : undefined,
|
||||
input.createdAt
|
||||
? between(
|
||||
organization.createdAt,
|
||||
dayjs(input.createdAt[0]).startOf("day").toDate(),
|
||||
dayjs(input.createdAt[1]).endOf("day").toDate(),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const having = input.members
|
||||
? between(
|
||||
sql<number>`CAST(COUNT(${member.id}) AS INTEGER)`,
|
||||
input.members[0],
|
||||
input.members[1],
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const orderBy =
|
||||
input.sort && input.sort.length > 0
|
||||
? input.sort.flatMap((s) => {
|
||||
const field = s.id.split(/[_.]/).pop() ?? s.id;
|
||||
if (field === "members") {
|
||||
return [s.desc ? sql`members DESC` : sql`members ASC`];
|
||||
}
|
||||
return getOrderByFromSort({ sort: [s], defaultSchema: organization });
|
||||
})
|
||||
: [asc(organization.name)];
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const results = await tx
|
||||
.select({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug,
|
||||
logo: organization.logo,
|
||||
createdAt: organization.createdAt,
|
||||
members: sql<number>`CAST(COUNT(${member.id}) AS INTEGER)`.as(
|
||||
"members",
|
||||
),
|
||||
total: sql<number>`COUNT(*) OVER()`.mapWith(Number).as("total"),
|
||||
})
|
||||
.from(organization)
|
||||
.leftJoin(member, eq(member.organizationId, organization.id))
|
||||
.where(where)
|
||||
.groupBy(organization.id)
|
||||
.having(having)
|
||||
.limit(input.perPage)
|
||||
.offset(offset)
|
||||
.orderBy(...orderBy);
|
||||
|
||||
const membersMax = await tx
|
||||
.select({
|
||||
members: sql<number>`CAST(COUNT(${member.id}) AS INTEGER)`.as(
|
||||
"members",
|
||||
),
|
||||
})
|
||||
.from(member)
|
||||
.groupBy(member.organizationId)
|
||||
.orderBy(sql`members DESC`)
|
||||
.limit(1)
|
||||
.then((res) => res[0]?.members ?? 0);
|
||||
|
||||
const data = results.map(({ total: _, ...rest }) => rest);
|
||||
const total = results[0]?.total ?? 0;
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
max: { members: membersMax },
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getOrganization = async ({ id }: { id: string }) => {
|
||||
return (
|
||||
(await db.query.organization.findFirst({
|
||||
where: eq(organization.id, id),
|
||||
})) ?? null
|
||||
);
|
||||
};
|
||||
89
packages/api/src/modules/admin/organizations/router.ts
Normal file
89
packages/api/src/modules/admin/organizations/router.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import {
|
||||
updateMemberSchema,
|
||||
updateOrganizationSchema,
|
||||
} from "@turbostarter/auth";
|
||||
|
||||
import { validate } from "../../../middleware";
|
||||
import {
|
||||
getInvitationsInputSchema,
|
||||
getMembersInputSchema,
|
||||
getOrganizationsInputSchema,
|
||||
} from "../../../schema";
|
||||
import { getInvitations } from "../../organization/queries/invitations";
|
||||
import { getMembers } from "../../organization/queries/members";
|
||||
|
||||
import {
|
||||
deleteOrganization,
|
||||
deleteOrganizationInvitation,
|
||||
deleteOrganizationMember,
|
||||
updateOrganizationMember,
|
||||
} from "./mutations";
|
||||
import { updateOrganization } from "./mutations";
|
||||
import { getOrganizations, getOrganization } from "./queries";
|
||||
|
||||
export const organizationsRouter = new Hono()
|
||||
.get("/", validate("query", getOrganizationsInputSchema), async (c) =>
|
||||
c.json(await getOrganizations(c.req.valid("query"))),
|
||||
)
|
||||
.get("/:id", async (c) =>
|
||||
c.json(await getOrganization({ id: c.req.param("id") })),
|
||||
)
|
||||
.delete("/:id", async (c) =>
|
||||
c.json(await deleteOrganization({ id: c.req.param("id") })),
|
||||
)
|
||||
.patch("/:id", validate("json", updateOrganizationSchema), async (c) =>
|
||||
c.json(
|
||||
await updateOrganization({
|
||||
id: c.req.param("id"),
|
||||
data: c.req.valid("json"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.get("/:id/members", validate("query", getMembersInputSchema), async (c) =>
|
||||
c.json(
|
||||
await getMembers({
|
||||
organizationId: c.req.param("id"),
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.patch(
|
||||
"/:id/members/:memberId",
|
||||
validate("json", updateMemberSchema),
|
||||
async (c) =>
|
||||
c.json(
|
||||
await updateOrganizationMember({
|
||||
id: c.req.param("memberId"),
|
||||
data: c.req.valid("json"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.delete("/:id/members/:memberId", async (c) =>
|
||||
c.json(
|
||||
await deleteOrganizationMember({
|
||||
id: c.req.param("memberId"),
|
||||
organizationId: c.req.param("id"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.get(
|
||||
"/:id/invitations",
|
||||
validate("query", getInvitationsInputSchema),
|
||||
async (c) =>
|
||||
c.json(
|
||||
await getInvitations({
|
||||
organizationId: c.req.param("id"),
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.delete("/:id/invitations/:invitationId", async (c) =>
|
||||
c.json(
|
||||
await deleteOrganizationInvitation({
|
||||
id: c.req.param("invitationId"),
|
||||
organizationId: c.req.param("id"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
26
packages/api/src/modules/admin/router.ts
Normal file
26
packages/api/src/modules/admin/router.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { enforceAdmin, enforceAuth } from "../../middleware";
|
||||
|
||||
import { getCustomersCount } from "./customers/queries";
|
||||
import { customersRouter } from "./customers/router";
|
||||
import { getOrganizationsCount } from "./organizations/queries";
|
||||
import { organizationsRouter } from "./organizations/router";
|
||||
import { getUsersCount } from "./users/queries";
|
||||
import { usersRouter } from "./users/router";
|
||||
|
||||
export const adminRouter = new Hono()
|
||||
.use(enforceAuth)
|
||||
.use(enforceAdmin)
|
||||
.route("/users", usersRouter)
|
||||
.route("/organizations", organizationsRouter)
|
||||
.route("/customers", customersRouter)
|
||||
.get("/summary", async (c) => {
|
||||
const [users, organizations, customers] = await Promise.all([
|
||||
getUsersCount(),
|
||||
getOrganizationsCount(),
|
||||
getCustomersCount(),
|
||||
]);
|
||||
|
||||
return c.json({ users, organizations, customers });
|
||||
});
|
||||
6
packages/api/src/modules/admin/users/mutations.ts
Normal file
6
packages/api/src/modules/admin/users/mutations.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { eq } from "@turbostarter/db";
|
||||
import { account } from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
|
||||
export const deleteAccount = async ({ id }: { id: string }) =>
|
||||
db.delete(account).where(eq(account.id, id));
|
||||
332
packages/api/src/modules/admin/users/queries.ts
Normal file
332
packages/api/src/modules/admin/users/queries.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
between,
|
||||
count,
|
||||
eq,
|
||||
getOrderByFromSort,
|
||||
ilike,
|
||||
inArray,
|
||||
or,
|
||||
} from "@turbostarter/db";
|
||||
import {
|
||||
account,
|
||||
customer,
|
||||
invitation,
|
||||
member,
|
||||
organization,
|
||||
user,
|
||||
} from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
|
||||
import type {
|
||||
GetUserAccountsInput,
|
||||
GetUserInvitationsInput,
|
||||
GetUserMembershipsInput,
|
||||
GetUserPlansInput,
|
||||
GetUsersInput,
|
||||
} from "../../../schema";
|
||||
|
||||
export const getUsersCount = async () =>
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(user)
|
||||
.then((res) => res[0]?.count ?? 0);
|
||||
|
||||
export const getUsers = async (input: GetUsersInput) => {
|
||||
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(user.role, input.role) : undefined,
|
||||
input.twoFactorEnabled
|
||||
? inArray(user.twoFactorEnabled, input.twoFactorEnabled)
|
||||
: undefined,
|
||||
input.banned ? inArray(user.banned, input.banned) : undefined,
|
||||
input.createdAt
|
||||
? between(
|
||||
user.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: user })
|
||||
: [asc(user.name)];
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const data = await tx
|
||||
.select()
|
||||
.from(user)
|
||||
.where(where)
|
||||
.limit(input.perPage)
|
||||
.offset(offset)
|
||||
.orderBy(...orderBy);
|
||||
|
||||
const total = await tx
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(user)
|
||||
.where(where)
|
||||
.execute()
|
||||
.then((res) => res[0]?.count ?? 0);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserAccounts = async ({
|
||||
userId,
|
||||
...input
|
||||
}: GetUserAccountsInput & { userId: string }) => {
|
||||
const offset = (input.page - 1) * input.perPage;
|
||||
|
||||
const where = and(
|
||||
input.providerId
|
||||
? inArray(account.providerId, input.providerId)
|
||||
: undefined,
|
||||
input.createdAt
|
||||
? between(
|
||||
account.createdAt,
|
||||
dayjs(input.createdAt[0]).startOf("day").toDate(),
|
||||
dayjs(input.createdAt[1]).endOf("day").toDate(),
|
||||
)
|
||||
: undefined,
|
||||
input.updatedAt
|
||||
? between(
|
||||
account.updatedAt,
|
||||
dayjs(input.updatedAt[0]).startOf("day").toDate(),
|
||||
dayjs(input.updatedAt[1]).endOf("day").toDate(),
|
||||
)
|
||||
: undefined,
|
||||
eq(account.userId, userId),
|
||||
);
|
||||
|
||||
const orderBy = input.sort
|
||||
? getOrderByFromSort({ sort: input.sort, defaultSchema: account })
|
||||
: [asc(account.providerId)];
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const data = await tx
|
||||
.select()
|
||||
.from(account)
|
||||
.where(where)
|
||||
.limit(input.perPage)
|
||||
.offset(offset)
|
||||
.orderBy(...orderBy);
|
||||
|
||||
const total = await tx
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(account)
|
||||
.where(where)
|
||||
.execute()
|
||||
.then((res) => res[0]?.count ?? 0);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserPlans = async ({
|
||||
userId,
|
||||
...input
|
||||
}: GetUserPlansInput & { userId: string }) => {
|
||||
const offset = (input.page - 1) * input.perPage;
|
||||
|
||||
const where = and(
|
||||
input.plan ? inArray(customer.plan, input.plan) : undefined,
|
||||
input.status ? inArray(customer.status, input.status) : undefined,
|
||||
input.createdAt
|
||||
? between(
|
||||
customer.createdAt,
|
||||
dayjs(input.createdAt[0]).startOf("day").toDate(),
|
||||
dayjs(input.createdAt[1]).endOf("day").toDate(),
|
||||
)
|
||||
: undefined,
|
||||
eq(customer.userId, userId),
|
||||
);
|
||||
|
||||
const orderBy = input.sort
|
||||
? getOrderByFromSort({ sort: input.sort, defaultSchema: customer })
|
||||
: [asc(customer.plan)];
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const data = await tx
|
||||
.select({
|
||||
id: customer.id,
|
||||
userId: customer.userId,
|
||||
customerId: customer.customerId,
|
||||
plan: customer.plan,
|
||||
status: customer.status,
|
||||
credits: customer.credits,
|
||||
createdAt: customer.createdAt,
|
||||
updatedAt: customer.updatedAt,
|
||||
user: {
|
||||
name: user.name,
|
||||
},
|
||||
})
|
||||
.from(customer)
|
||||
.leftJoin(user, eq(customer.userId, user.id))
|
||||
.where(where)
|
||||
.limit(input.perPage)
|
||||
.offset(offset)
|
||||
.orderBy(...orderBy);
|
||||
|
||||
const total = await tx
|
||||
.select({ count: count() })
|
||||
.from(customer)
|
||||
.where(where)
|
||||
.execute()
|
||||
.then((res) => res[0]?.count ?? 0);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserMemberships = async ({
|
||||
userId,
|
||||
...input
|
||||
}: GetUserMembershipsInput & { userId: string }) => {
|
||||
const offset = (input.page - 1) * input.perPage;
|
||||
|
||||
const where = and(
|
||||
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.userId, userId),
|
||||
);
|
||||
|
||||
const orderBy = input.sort
|
||||
? getOrderByFromSort({ sort: input.sort, defaultSchema: member })
|
||||
: [asc(organization.name)];
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const data = await tx
|
||||
.select({
|
||||
id: member.id,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userId: member.userId,
|
||||
organization: {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug,
|
||||
logo: organization.logo,
|
||||
},
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
},
|
||||
})
|
||||
.from(member)
|
||||
.leftJoin(organization, eq(member.organizationId, organization.id))
|
||||
.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(organization, eq(member.organizationId, organization.id))
|
||||
.where(where)
|
||||
.execute()
|
||||
.then((res) => res[0]?.count ?? 0);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserInvitations = async ({
|
||||
userId,
|
||||
...input
|
||||
}: GetUserInvitationsInput & { userId: string }) => {
|
||||
const offset = (input.page - 1) * input.perPage;
|
||||
|
||||
const where = and(
|
||||
input.status ? inArray(invitation.status, input.status) : undefined,
|
||||
input.role ? inArray(invitation.role, input.role) : undefined,
|
||||
input.expiresAt
|
||||
? between(
|
||||
invitation.expiresAt,
|
||||
dayjs(input.expiresAt[0]).startOf("day").toDate(),
|
||||
dayjs(input.expiresAt[1]).endOf("day").toDate(),
|
||||
)
|
||||
: undefined,
|
||||
eq(user.id, userId),
|
||||
);
|
||||
|
||||
const orderBy = input.sort
|
||||
? getOrderByFromSort({ sort: input.sort, defaultSchema: invitation })
|
||||
: [asc(organization.name)];
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const data = await tx
|
||||
.select({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
status: invitation.status,
|
||||
expiresAt: invitation.expiresAt,
|
||||
inviterId: invitation.inviterId,
|
||||
organizationId: invitation.organizationId,
|
||||
organization: {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
logo: organization.logo,
|
||||
},
|
||||
})
|
||||
.from(invitation)
|
||||
.leftJoin(organization, eq(invitation.organizationId, organization.id))
|
||||
.leftJoin(user, eq(invitation.email, user.email))
|
||||
.where(where)
|
||||
.limit(input.perPage)
|
||||
.offset(offset)
|
||||
.orderBy(...orderBy);
|
||||
|
||||
const total = await tx
|
||||
.select({ count: count() })
|
||||
.from(invitation)
|
||||
.leftJoin(organization, eq(invitation.organizationId, organization.id))
|
||||
.leftJoin(user, eq(invitation.email, user.email))
|
||||
.where(where)
|
||||
.execute()
|
||||
.then((res) => res[0]?.count ?? 0);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
};
|
||||
});
|
||||
};
|
||||
68
packages/api/src/modules/admin/users/router.ts
Normal file
68
packages/api/src/modules/admin/users/router.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { validate } from "../../../middleware";
|
||||
import {
|
||||
getUserAccountsInputSchema,
|
||||
getUsersInputSchema,
|
||||
getUserMembershipsInputSchema,
|
||||
getUserInvitationsInputSchema,
|
||||
getUserPlansInputSchema,
|
||||
} from "../../../schema";
|
||||
|
||||
import { deleteAccount } from "./mutations";
|
||||
import {
|
||||
getUsers,
|
||||
getUserAccounts,
|
||||
getUserPlans,
|
||||
getUserMemberships,
|
||||
getUserInvitations,
|
||||
} from "./queries";
|
||||
|
||||
export const usersRouter = new Hono()
|
||||
.get("/", validate("query", getUsersInputSchema), async (c) =>
|
||||
c.json(await getUsers(c.req.valid("query"))),
|
||||
)
|
||||
.get(
|
||||
"/:id/accounts",
|
||||
validate("query", getUserAccountsInputSchema),
|
||||
async (c) =>
|
||||
c.json(
|
||||
await getUserAccounts({
|
||||
userId: c.req.param("id"),
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.get("/:id/plans", validate("query", getUserPlansInputSchema), async (c) =>
|
||||
c.json(
|
||||
await getUserPlans({
|
||||
userId: c.req.param("id"),
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.get(
|
||||
"/:id/memberships",
|
||||
validate("query", getUserMembershipsInputSchema),
|
||||
async (c) =>
|
||||
c.json(
|
||||
await getUserMemberships({
|
||||
userId: c.req.param("id"),
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.get(
|
||||
"/:id/invitations",
|
||||
validate("query", getUserInvitationsInputSchema),
|
||||
async (c) =>
|
||||
c.json(
|
||||
await getUserInvitations({
|
||||
userId: c.req.param("id"),
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.delete("/:id/accounts/:accountId", async (c) =>
|
||||
c.json(await deleteAccount({ id: c.req.param("accountId") })),
|
||||
);
|
||||
48
packages/api/src/modules/ai/chat.ts
Normal file
48
packages/api/src/modules/ai/chat.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import {
|
||||
getUserChats,
|
||||
deleteChat,
|
||||
getChat,
|
||||
streamChat,
|
||||
getChatMessagesWithAttachments,
|
||||
} from "@turbostarter/ai/chat/api";
|
||||
import { chatMessageSchema } from "@turbostarter/ai/chat/schema";
|
||||
import { getCreditsDeduction } from "@turbostarter/ai/chat/utils";
|
||||
|
||||
import { deductCredits, enforceAuth, rateLimiter, validate } from "../../middleware";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
const chatsRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>()
|
||||
.post(
|
||||
"/",
|
||||
enforceAuth,
|
||||
rateLimiter,
|
||||
validate("json", chatMessageSchema),
|
||||
async (c) => {
|
||||
const input = c.req.valid("json");
|
||||
const creditsAmount = getCreditsDeduction(input.metadata.options, input.parts);
|
||||
|
||||
// Deduct credits
|
||||
await deductCredits(creditsAmount, "chat")(c, async () => { /* noop */ });
|
||||
|
||||
return streamChat({
|
||||
...input,
|
||||
signal: c.req.raw.signal,
|
||||
userId: c.var.user.id,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get("/", enforceAuth, async (c) => c.json(await getUserChats(c.var.user.id)))
|
||||
.delete("/:id", enforceAuth, async (c) => c.json(await deleteChat(c.req.param("id"))))
|
||||
.get("/:id", enforceAuth, async (c) => c.json((await getChat(c.req.param("id"))) ?? null))
|
||||
.get("/:id/messages", enforceAuth, async (c) =>
|
||||
c.json(await getChatMessagesWithAttachments(c.req.param("id"))),
|
||||
);
|
||||
|
||||
export const chatRouter = new Hono().route("/chats", chatsRouter);
|
||||
89
packages/api/src/modules/ai/image.ts
Normal file
89
packages/api/src/modules/ai/image.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Hono } from "hono";
|
||||
import * as z from "zod";
|
||||
|
||||
import { Credits } from "@turbostarter/ai/credits/utils";
|
||||
import {
|
||||
createGeneration,
|
||||
generateImages,
|
||||
getGeneration,
|
||||
getGenerationImages,
|
||||
getImages,
|
||||
} from "@turbostarter/ai/image/api";
|
||||
import { imageGenerationSchema } from "@turbostarter/ai/image/schema";
|
||||
|
||||
import { deductCredits, enforceAuth, rateLimiter, validate } from "../../middleware";
|
||||
import { withTimeout } from "../../utils";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
const generationsRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>()
|
||||
.post(
|
||||
"/",
|
||||
enforceAuth,
|
||||
validate("json", imageGenerationSchema),
|
||||
async (c) => {
|
||||
const input = c.req.valid("json");
|
||||
const creditsAmount = input.options.count * Credits.COST.DEFAULT;
|
||||
|
||||
// Deduct credits
|
||||
await deductCredits(creditsAmount, "image-generation")(c, async () => { /* noop */ });
|
||||
|
||||
return c.json(
|
||||
await createGeneration({
|
||||
userId: c.var.user.id,
|
||||
...input,
|
||||
...input.options,
|
||||
}),
|
||||
);
|
||||
},
|
||||
)
|
||||
.post("/:id/images", enforceAuth, rateLimiter, async (c) =>
|
||||
c.json(
|
||||
await withTimeout(
|
||||
generateImages({
|
||||
id: c.req.param("id"),
|
||||
abortSignal: c.req.raw.signal,
|
||||
}),
|
||||
55 * 1000,
|
||||
),
|
||||
),
|
||||
)
|
||||
.get("/:id", enforceAuth, async (c) =>
|
||||
c.json((await getGeneration(c.req.param("id"))) ?? null),
|
||||
)
|
||||
.get("/:id/images", enforceAuth, async (c) =>
|
||||
c.json(await getGenerationImages(c.req.param("id"))),
|
||||
);
|
||||
|
||||
const imagesRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>().get(
|
||||
"/",
|
||||
enforceAuth,
|
||||
validate(
|
||||
"query",
|
||||
z
|
||||
.object({
|
||||
limit: z.number().optional(),
|
||||
cursor: z.coerce.date().optional(),
|
||||
})
|
||||
.optional(),
|
||||
),
|
||||
async (c) =>
|
||||
c.json(
|
||||
await getImages({
|
||||
userId: c.var.user.id,
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const imageRouter = new Hono()
|
||||
.route("/generations", generationsRouter)
|
||||
.route("/images", imagesRouter);
|
||||
254
packages/api/src/modules/ai/pdf.ts
Normal file
254
packages/api/src/modules/ai/pdf.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { Hono } from "hono";
|
||||
import * as z from "zod";
|
||||
|
||||
import { Credits } from "@turbostarter/ai/credits/utils";
|
||||
import {
|
||||
createChat,
|
||||
deleteChat,
|
||||
getChat,
|
||||
getChatDocuments,
|
||||
getChatMessages,
|
||||
getDocument,
|
||||
getUserChats,
|
||||
streamChatWithDocuments,
|
||||
} from "@turbostarter/ai/pdf/api";
|
||||
import { pdfMessageSchema } from "@turbostarter/ai/pdf/schema";
|
||||
import {
|
||||
searchWithCitations,
|
||||
getCitationUnitsForChunk,
|
||||
getCitationUnitById,
|
||||
} from "@turbostarter/ai/pdf/search";
|
||||
import {
|
||||
insertPdfChatSchema,
|
||||
insertPdfDocumentSchema,
|
||||
} from "@turbostarter/db/schema/pdf";
|
||||
|
||||
import { deductCredits, enforceAuth, rateLimiter, validate } from "../../middleware";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
const createChatSchema = z.object({
|
||||
...insertPdfChatSchema.omit({ userId: true }).shape,
|
||||
...insertPdfDocumentSchema.omit({ chatId: true }).shape,
|
||||
});
|
||||
|
||||
type _CreateChatInput = z.infer<typeof createChatSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Search Schemas
|
||||
// ============================================================================
|
||||
|
||||
const searchInputSchema = z.object({
|
||||
query: z.string().min(1),
|
||||
documentId: z.string(),
|
||||
limit: z.number().min(1).max(20).optional(),
|
||||
threshold: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
type _SearchInput = z.infer<typeof searchInputSchema>;
|
||||
|
||||
const chatsRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>()
|
||||
.post(
|
||||
"/",
|
||||
enforceAuth,
|
||||
rateLimiter,
|
||||
validate("json", createChatSchema),
|
||||
async (c) => {
|
||||
const input = c.req.valid("json");
|
||||
|
||||
// Deduct credits
|
||||
await deductCredits(Credits.COST.DEFAULT, "pdf-chat")(c, async () => { /* noop */ });
|
||||
|
||||
return c.json(
|
||||
await createChat({
|
||||
...input,
|
||||
userId: c.var.user.id,
|
||||
}),
|
||||
);
|
||||
},
|
||||
)
|
||||
.get("/", enforceAuth, async (c) => c.json(await getUserChats(c.var.user.id)))
|
||||
.get("/:id", enforceAuth, async (c) =>
|
||||
c.json((await getChat(c.req.param("id"))) ?? null),
|
||||
)
|
||||
.delete("/:id", enforceAuth, async (c) =>
|
||||
c.json(await deleteChat(c.req.param("id"))),
|
||||
)
|
||||
.post(
|
||||
"/:id/messages",
|
||||
enforceAuth,
|
||||
rateLimiter,
|
||||
validate("json", pdfMessageSchema),
|
||||
async (c) => {
|
||||
const input = c.req.valid("json");
|
||||
const chatId = c.req.param("id");
|
||||
|
||||
// Get documents for this chat to enable document-specific search
|
||||
const documents = await getChatDocuments(chatId);
|
||||
const documentIds = documents.map((d) => d.id);
|
||||
console.log(`📝 POST /:id/messages - chatId: ${chatId}, documents found: ${documents.length}, documentIds:`, documentIds);
|
||||
|
||||
// Deduct credits
|
||||
await deductCredits(Credits.COST.DEFAULT, "pdf-chat")(c, async () => { /* noop */ });
|
||||
|
||||
return streamChatWithDocuments({
|
||||
...input,
|
||||
signal: c.req.raw.signal,
|
||||
chatId,
|
||||
documentIds,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get("/:id/messages", enforceAuth, async (c) =>
|
||||
c.json(await getChatMessages(c.req.param("id"))),
|
||||
)
|
||||
.get("/:id/documents", enforceAuth, async (c) =>
|
||||
c.json(await getChatDocuments(c.req.param("id"))),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Embeddings Router
|
||||
// ============================================================================
|
||||
|
||||
const embeddingsRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>()
|
||||
.get("/:id", enforceAuth, async (c) => {
|
||||
const { getEmbeddingById } = await import("@turbostarter/ai/pdf/embeddings");
|
||||
const embedding = await getEmbeddingById(c.req.param("id"));
|
||||
if (!embedding) {
|
||||
return c.json({ error: "Embedding not found" }, 404);
|
||||
}
|
||||
return c.json(embedding);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Search Router (WF-0028 Dual-Resolution Search)
|
||||
// ============================================================================
|
||||
|
||||
const searchRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>()
|
||||
.post(
|
||||
"/",
|
||||
enforceAuth,
|
||||
validate("json", searchInputSchema),
|
||||
async (c) => {
|
||||
const input = c.req.valid("json");
|
||||
const results = await searchWithCitations(input.query, input.documentId, {
|
||||
limit: input.limit,
|
||||
threshold: input.threshold,
|
||||
});
|
||||
return c.json({ data: results });
|
||||
},
|
||||
)
|
||||
// NOTE: More specific route must come BEFORE generic :chunkId route
|
||||
.get("/citation-units/single/:id", enforceAuth, async (c) => {
|
||||
const unitId = c.req.param("id");
|
||||
const unit = await getCitationUnitById(unitId);
|
||||
if (!unit) {
|
||||
return c.json({ error: "Citation unit not found" }, 404);
|
||||
}
|
||||
return c.json({ data: unit });
|
||||
})
|
||||
.get("/citation-units/:chunkId", enforceAuth, async (c) => {
|
||||
const chunkId = c.req.param("chunkId");
|
||||
const units = await getCitationUnitsForChunk(chunkId);
|
||||
return c.json({ data: units });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Documents Router (document status and management)
|
||||
// ============================================================================
|
||||
|
||||
const documentsRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>()
|
||||
.get("/:id/status", enforceAuth, async (c) => {
|
||||
const document = await getDocument(c.req.param("id"));
|
||||
if (!document) {
|
||||
return c.json({ error: "Document not found" }, 404);
|
||||
}
|
||||
return c.json({
|
||||
id: document.id,
|
||||
processingStatus: document.processingStatus,
|
||||
processingError: document.processingError,
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Diagnostics Router (for debugging embedding issues)
|
||||
// ============================================================================
|
||||
|
||||
const diagnosticsRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>()
|
||||
.get("/chat/:chatId", enforceAuth, async (c) => {
|
||||
const { sql } = await import("@turbostarter/db");
|
||||
const { db } = await import("@turbostarter/db/server");
|
||||
|
||||
const chatId = c.req.param("chatId");
|
||||
|
||||
// Get documents for this chat
|
||||
const documents = await getChatDocuments(chatId);
|
||||
|
||||
if (documents.length === 0) {
|
||||
return c.json({ error: "No documents found for chat", chatId });
|
||||
}
|
||||
|
||||
// Get embedding counts per document
|
||||
const diagnostics = await Promise.all(
|
||||
documents.map(async (doc) => {
|
||||
const countResult = await db.execute<{ count: string }>(sql`
|
||||
SELECT COUNT(*) as count FROM pdf.embedding WHERE document_id = ${doc.id}
|
||||
`);
|
||||
const rows = Array.isArray(countResult) ? countResult : [];
|
||||
const count = parseInt(rows[0]?.count ?? "0", 10);
|
||||
|
||||
// Get sample content
|
||||
const sampleResult = await db.execute<{ content: string; page_number: number }>(sql`
|
||||
SELECT LEFT(content, 100) as content, page_number
|
||||
FROM pdf.embedding
|
||||
WHERE document_id = ${doc.id}
|
||||
LIMIT 2
|
||||
`);
|
||||
const samples = Array.isArray(sampleResult) ? sampleResult : [];
|
||||
|
||||
return {
|
||||
documentId: doc.id,
|
||||
documentName: doc.name,
|
||||
embeddingCount: count,
|
||||
samples: samples.map(s => ({
|
||||
preview: s.content,
|
||||
page: s.page_number,
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({
|
||||
chatId,
|
||||
documentCount: documents.length,
|
||||
documents: diagnostics,
|
||||
totalEmbeddings: diagnostics.reduce((sum, d) => sum + d.embeddingCount, 0),
|
||||
});
|
||||
});
|
||||
|
||||
export const pdfRouter = new Hono()
|
||||
.route("/chats", chatsRouter)
|
||||
.route("/documents", documentsRouter)
|
||||
.route("/embeddings", embeddingsRouter)
|
||||
.route("/search", searchRouter)
|
||||
.route("/diagnostics", diagnosticsRouter);
|
||||
20
packages/api/src/modules/ai/router.ts
Normal file
20
packages/api/src/modules/ai/router.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { getUserCredits } from "@turbostarter/ai/credits/server";
|
||||
|
||||
import { enforceAuth } from "../../middleware";
|
||||
|
||||
import { chatRouter } from "./chat";
|
||||
import { imageRouter } from "./image";
|
||||
import { pdfRouter } from "./pdf";
|
||||
import { sttRouter } from "./stt";
|
||||
import { ttsRouter } from "./tts";
|
||||
|
||||
export const aiRouter = new Hono()
|
||||
.use(enforceAuth)
|
||||
.route("/chat", chatRouter)
|
||||
.route("/pdf", pdfRouter)
|
||||
.route("/image", imageRouter)
|
||||
.route("/tts", ttsRouter)
|
||||
.route("/stt", sttRouter)
|
||||
.get("/credits", async (c) => c.json(await getUserCredits(c.var.user.id)));
|
||||
55
packages/api/src/modules/ai/stt.ts
Normal file
55
packages/api/src/modules/ai/stt.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { Credits } from "@turbostarter/ai/credits/utils";
|
||||
import { transcribe } from "@turbostarter/ai/stt/api";
|
||||
import { transcriptionOptionsSchema } from "@turbostarter/ai/stt/schema";
|
||||
|
||||
import { deductCredits, enforceAuth, rateLimiter } from "../../middleware";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
export const sttRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>().post("/", enforceAuth, rateLimiter, async (c) => {
|
||||
console.log("[STT] Request received");
|
||||
|
||||
// Use Hono's typed FormData methods to work across different runtime environments
|
||||
const formData = await c.req.formData();
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
const audioFile = ((formData as any).get?.("audio") ?? (formData as any).getAll?.("audio")?.[0]) as File | null;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
console.log("[STT] Audio file:", audioFile ? `${audioFile.name} (${audioFile.size} bytes, ${audioFile.type})` : "null");
|
||||
|
||||
if (!audioFile) {
|
||||
return c.json({ error: "No audio file provided" }, 400);
|
||||
}
|
||||
|
||||
// Parse optional parameters
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
const fd = formData as any;
|
||||
const language = (fd.get?.("language") ?? fd.getAll?.("language")?.[0]) as string | null;
|
||||
const prompt = (fd.get?.("prompt") ?? fd.getAll?.("prompt")?.[0]) as string | null;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
const options = transcriptionOptionsSchema.parse({
|
||||
language: language ?? undefined,
|
||||
prompt: prompt ?? undefined,
|
||||
});
|
||||
|
||||
// Deduct credits
|
||||
console.log("[STT] Deducting credits...");
|
||||
await deductCredits(Credits.COST.DEFAULT, "speech-to-text")(c, async () => { /* noop */ });
|
||||
console.log("[STT] Credits deducted, calling OpenAI Whisper...");
|
||||
|
||||
try {
|
||||
const result = await transcribe(audioFile, options);
|
||||
console.log("[STT] Transcription successful:", result.text.substring(0, 50));
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error("[STT] Transcription error:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
40
packages/api/src/modules/ai/tts.ts
Normal file
40
packages/api/src/modules/ai/tts.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { Credits } from "@turbostarter/ai/credits/utils";
|
||||
import { getVoices, textToSpeech } from "@turbostarter/ai/tts/api";
|
||||
import { ttsSchema } from "@turbostarter/ai/tts/schema";
|
||||
|
||||
import { deductCredits, enforceAuth, rateLimiter, validate } from "../../middleware";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
export const ttsRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>()
|
||||
.post(
|
||||
"/",
|
||||
enforceAuth,
|
||||
rateLimiter,
|
||||
validate("json", ttsSchema),
|
||||
async (c) => {
|
||||
const input = c.req.valid("json");
|
||||
|
||||
// Deduct credits
|
||||
await deductCredits(Credits.COST.HIGH, "text-to-speech")(c, async () => { /* noop */ });
|
||||
|
||||
return new Response(
|
||||
(await textToSpeech(input)) as unknown as ConstructorParameters<
|
||||
typeof Response
|
||||
>[0],
|
||||
{
|
||||
headers: { "Content-Type": "audio/mpeg" },
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.get("/voices", enforceAuth, async (c) => {
|
||||
const voices = await getVoices();
|
||||
return c.json(voices);
|
||||
});
|
||||
27
packages/api/src/modules/auth/router.ts
Normal file
27
packages/api/src/modules/auth/router.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { ERROR_MESSAGES } from "@turbostarter/auth";
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
import { HttpStatusCode } from "@turbostarter/shared/constants";
|
||||
import { HttpException, isHttpStatus } from "@turbostarter/shared/utils";
|
||||
|
||||
import type { AuthErrorCode } from "@turbostarter/auth";
|
||||
|
||||
export const authRouter = new Hono().on(["GET", "POST"], "*", async (c) => {
|
||||
const res = await auth.handler(c.req.raw);
|
||||
|
||||
if (["2", "3"].includes(res.status.toString().slice(0, 1))) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as { code: AuthErrorCode; message: string };
|
||||
|
||||
throw new HttpException(
|
||||
isHttpStatus(res.status)
|
||||
? res.status
|
||||
: HttpStatusCode.INTERNAL_SERVER_ERROR,
|
||||
{
|
||||
code: ERROR_MESSAGES[json.code],
|
||||
},
|
||||
);
|
||||
});
|
||||
38
packages/api/src/modules/billing/router.ts
Normal file
38
packages/api/src/modules/billing/router.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import {
|
||||
checkoutSchema,
|
||||
checkout,
|
||||
getBillingPortalSchema,
|
||||
getBillingPortal,
|
||||
webhookHandler,
|
||||
getCustomerByUserId,
|
||||
} from "@turbostarter/billing/server";
|
||||
|
||||
import { enforceAuth, validate } from "../../middleware";
|
||||
|
||||
export const billingRouter = new Hono()
|
||||
.post("/checkout", validate("json", checkoutSchema), enforceAuth, async (c) =>
|
||||
c.json(
|
||||
await checkout({
|
||||
user: c.var.user,
|
||||
...c.req.valid("json"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.get(
|
||||
"/portal",
|
||||
enforceAuth,
|
||||
validate("query", getBillingPortalSchema),
|
||||
async (c) =>
|
||||
c.json(
|
||||
await getBillingPortal({
|
||||
user: c.var.user,
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.get("/customer", enforceAuth, async (c) =>
|
||||
c.json(await getCustomerByUserId(c.var.user.id)),
|
||||
)
|
||||
.post("/webhook", (c) => webhookHandler(c.req.raw));
|
||||
@@ -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"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
99
packages/api/src/modules/storage/router.ts
Normal file
99
packages/api/src/modules/storage/router.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
getObjectUrlSchema,
|
||||
getUploadUrl,
|
||||
getSignedUrl,
|
||||
getPublicUrl,
|
||||
getDeleteUrl,
|
||||
} from "@turbostarter/storage/server";
|
||||
|
||||
import { enforceAuth, validate } from "../../middleware";
|
||||
|
||||
const proxyFetchSchema = z.object({
|
||||
url: z.string().url(),
|
||||
validate: z.coerce.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export const storageRouter = new Hono()
|
||||
.get(
|
||||
"/upload",
|
||||
enforceAuth,
|
||||
validate("query", getObjectUrlSchema),
|
||||
async (c) => c.json(await getUploadUrl(c.req.valid("query"))),
|
||||
)
|
||||
.get("/public", validate("query", getObjectUrlSchema), async (c) =>
|
||||
c.json(await getPublicUrl(c.req.valid("query"))),
|
||||
)
|
||||
.get(
|
||||
"/signed",
|
||||
enforceAuth,
|
||||
validate("query", getObjectUrlSchema),
|
||||
async (c) => c.json(await getSignedUrl(c.req.valid("query"))),
|
||||
)
|
||||
.get(
|
||||
"/delete",
|
||||
enforceAuth,
|
||||
validate("query", getObjectUrlSchema),
|
||||
async (c) => c.json(await getDeleteUrl(c.req.valid("query"))),
|
||||
)
|
||||
.get(
|
||||
"/proxy",
|
||||
enforceAuth,
|
||||
validate("query", proxyFetchSchema),
|
||||
async (c) => {
|
||||
const { url, validate: validateOnly } = c.req.valid("query");
|
||||
|
||||
// Do a HEAD request to validate the URL
|
||||
const headResponse = await fetch(url, {
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
"User-Agent": "TurboStarter/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!headResponse.ok) {
|
||||
return c.json(
|
||||
{ error: "Failed to fetch URL", status: headResponse.status },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = headResponse.headers.get("content-type");
|
||||
if (!contentType?.includes("application/pdf")) {
|
||||
return c.json(
|
||||
{ error: "URL does not point to a PDF file" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = headResponse.headers.get("content-length") ?? "0";
|
||||
|
||||
// If just validating, return headers only
|
||||
if (validateOnly) {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Length": contentLength,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch the actual content
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "TurboStarter/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
const blob = await response.blob();
|
||||
return new Response(blob, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Length": blob.size.toString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user