feat: whyrating - initial project from turbostarter boilerplate

This commit is contained in:
Alejandro Gutiérrez
2026-02-04 01:54:52 +01:00
commit 5cdc07cd39
1618 changed files with 338230 additions and 0 deletions

33
packages/api/src/env.ts Normal file
View File

@@ -0,0 +1,33 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { defineEnv } from "envin";
import * as z from "zod";
import { preset as auth } from "@turbostarter/auth/env";
import { preset as billing } from "@turbostarter/billing/env";
import { preset as db } from "@turbostarter/db/env";
import { preset as email } from "@turbostarter/email/env";
import { preset as monitoring } from "@turbostarter/monitoring-web/env";
import { envConfig } from "@turbostarter/shared/constants";
import { preset as storage } from "@turbostarter/storage/env";
import type { Preset } from "envin/types";
export const preset = {
id: "api",
server: {
OPENAI_API_KEY: z.string().optional(), // change it to your provider API key (e.g. ANTHROPIC_API_KEY if you use Anthropic)
},
extends: [billing, auth, db, email, storage, monitoring],
} as const satisfies Preset;
export const env = defineEnv({
...envConfig,
...preset,
env: {
...process.env,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
},
});

58
packages/api/src/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import { Hono } from "hono";
import { some } from "hono/combine";
import { cors } from "hono/cors";
import { csrf } from "hono/csrf";
import { logger as loggerMiddleware } from "hono/logger";
import { auth } from "@turbostarter/auth/server";
import { logger } from "@turbostarter/shared/logger";
import { matchesPattern } from "@turbostarter/shared/utils";
import { localize, delay } from "./middleware";
import { adminRouter } from "./modules/admin/router";
import { aiRouter } from "./modules/ai/router";
import { authRouter } from "./modules/auth/router";
import { billingRouter } from "./modules/billing/router";
import { organizationRouter } from "./modules/organization/router";
import { storageRouter } from "./modules/storage/router";
import { onError } from "./utils/on-error";
import type { Context } from "hono";
const appRouter = new Hono()
.basePath("/api")
.use(
some(
(c: Context) => !!c.req.header("x-client-platform")?.startsWith("mobile"),
csrf({
origin: (origin, c) =>
[...auth.options.trustedOrigins, new URL(c.req.url).origin].some(
(trustedOrigin) => matchesPattern(origin, trustedOrigin),
),
}),
),
)
.use(
cors({
origin: "*",
allowHeaders: ["Content-Type", "Authorization"],
maxAge: 3600,
credentials: true,
}),
)
.use(loggerMiddleware((...args) => logger.info(...args)))
.use(delay)
.use(localize)
.get("/health", (c) => c.json({ status: "ok" }))
.route("/admin", adminRouter)
.route("/ai", aiRouter)
.route("/auth", authRouter)
.route("/billing", billingRouter)
.route("/organizations", organizationRouter)
.route("/storage", storageRouter)
.onError(onError);
type AppRouter = typeof appRouter;
export type { AppRouter };
export { appRouter };

View File

@@ -0,0 +1,338 @@
import { zValidator } from "@hono/zod-validator";
import { eq, sql } from "drizzle-orm";
import { env } from "hono/adapter";
import { createMiddleware } from "hono/factory";
import { getAllRolesAtOrAbove, hasAdminPermission } from "@turbostarter/auth";
import { MemberRole } from "@turbostarter/auth";
import { auth } from "@turbostarter/auth/server";
import { creditTransaction, customer } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import { makeZodI18nMap } from "@turbostarter/i18n";
import {
getLocaleFromRequest,
getTranslation,
} from "@turbostarter/i18n/server";
import { HttpStatusCode, NodeEnv } from "@turbostarter/shared/constants";
import { generateId, HttpException } from "@turbostarter/shared/utils";
import type { User } from "@turbostarter/auth";
import type { TFunction } from "@turbostarter/i18n";
import type { Context, ValidationTargets } from "hono";
import type { $ZodRawIssue, $ZodType } from "zod/v4/core";
type PermissionsInput = NonNullable<
NonNullable<
Parameters<typeof auth.api.hasPermission>[0]
>["body"]["permissions"]
>;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
export const enforceAuth = createMiddleware<{
Variables: {
user: User;
};
}>(async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
const user = session?.user ?? null;
if (!user) {
throw new HttpException(HttpStatusCode.UNAUTHORIZED, {
code: "error.unauthorized",
});
}
c.set("user", user);
await next();
});
/**
* Reusable middleware that enforces that the authenticated user
* has global admin permissions
*/
export const enforceAdmin = createMiddleware<{
Variables: {
user: User;
};
}>(async (c, next) => {
const user = c.var.user;
if (!hasAdminPermission(user)) {
throw new HttpException(HttpStatusCode.FORBIDDEN, {
code: "error.forbidden",
});
}
await next();
});
/**
* Reusable middleware that enforces that the authenticated user
* has the specified permissions in the user scope
*/
export const enforceUserPermission = ({
permissions,
}: {
permissions: PermissionsInput;
}) =>
createMiddleware<{ Variables: { user: User } }>(async (c, next) => {
const hasPermission = await auth.api.hasPermission({
body: {
permissions,
},
headers: c.req.raw.headers,
});
if (!hasPermission.success) {
throw new HttpException(HttpStatusCode.FORBIDDEN, {
code: "error.forbidden",
});
}
await next();
});
/**
* Middleware to enforce that the authenticated user has the required permissions
* for a given organization before allowing access to the route handler.
*/
export const enforceOrganizationPermission = ({
organizationId,
permissions,
}: {
organizationId?: string;
permissions: PermissionsInput;
}) =>
createMiddleware<{
Variables: {
user: User;
};
}>(async (c, next) => {
const hasPermission = await auth.api.hasPermission({
body: {
organizationId,
permissions,
},
headers: c.req.raw.headers,
});
if (!hasPermission.success) {
throw new HttpException(HttpStatusCode.FORBIDDEN, {
code: "error.forbidden",
});
}
await next();
});
/**
* Middleware to enforce that the authenticated user is at least a member
* of the given organization before allowing access to the route handler.
*/
export const enforceMembership = ({
organizationId,
role = MemberRole.MEMBER,
}: {
organizationId: string;
role?: MemberRole;
}) =>
createMiddleware<{
Variables: {
user: User;
};
}>(async (c, next) => {
const user = c.var.user;
try {
const { members } = await auth.api.listMembers({
query: {
organizationId,
filterField: "userId",
filterValue: user.id,
filterOperator: "eq",
},
headers: c.req.raw.headers,
});
const member = members.find((member) => member.userId === user.id);
if (!member || !getAllRolesAtOrAbove(role).includes(member.role)) {
throw new HttpException(HttpStatusCode.FORBIDDEN, {
code: "error.forbidden",
});
}
} catch {
throw new HttpException(HttpStatusCode.FORBIDDEN, {
code: "error.forbidden",
});
}
await next();
});
/**
* Middleware for adding an articifial delay in development.
*
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
* network latency that would occur in production but not in local development.
*/
export const delay = createMiddleware<{
Bindings: {
NODE_ENV: string;
};
}>(async (c, next) => {
if (env(c).NODE_ENV === NodeEnv.DEVELOPMENT) {
// artificial delay in dev 100-500ms
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
await next();
});
/**
* Middleware for setting the language based on the cookie and accept-language header.
*/
export const localize = createMiddleware<{
Variables: {
locale: string;
};
}>(async (c, next) => {
const locale = getLocaleFromRequest(c.req.raw);
c.set("locale", locale);
await next();
});
/**
* Middleware for validating the request input using Zod.
*/
export const validate = <
T extends $ZodType,
Target extends keyof ValidationTargets,
>(
target: Target,
schema: T,
) =>
zValidator(
target,
schema,
async (result, c: Context<{ Variables: { locale?: string } }, string>) => {
if (!result.success) {
const { t } = await getTranslation({
locale: c.var.locale,
});
const error = result.error.issues[0];
if (!error) {
throw new HttpException(HttpStatusCode.UNPROCESSABLE_ENTITY);
}
const { message, code } = makeZodI18nMap({ t: t as TFunction })(
error as $ZodRawIssue,
);
throw new HttpException(HttpStatusCode.UNPROCESSABLE_ENTITY, {
code,
message,
});
}
},
);
/**
* Simple in-memory rate limiter middleware for AI endpoints.
* Limits requests per user per time window.
*/
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
export const rateLimiter = createMiddleware<{
Variables: {
user: User;
};
}>(async (c, next) => {
const user = c.var.user;
const windowMs = 60 * 1000; // 1 minute
const maxRequests = 30; // 30 requests per minute
const now = Date.now();
const key = `rate-limit:${user.id}`;
const record = rateLimitStore.get(key);
if (!record || record.resetAt < now) {
rateLimitStore.set(key, { count: 1, resetAt: now + windowMs });
await next();
return;
}
if (record.count >= maxRequests) {
throw new HttpException(HttpStatusCode.TOO_MANY_REQUESTS, {
code: "error.rateLimit",
message: "Too many requests. Please try again later.",
});
}
record.count += 1;
await next();
});
/**
* Middleware to deduct credits from a user's account before processing AI requests.
* Takes the amount of credits to deduct and optional feature name for audit logging.
*/
export const deductCredits = (amount: number, feature?: string) =>
createMiddleware<{
Variables: {
user: User;
};
}>(async (c, next) => {
const user = c.var.user;
// Find user's customer record
const [customerRecord] = await db
.select()
.from(customer)
.where(eq(customer.userId, user.id));
if (!customerRecord) {
throw new HttpException(HttpStatusCode.FORBIDDEN, {
code: "error.noCredits",
message: "No subscription found. Please subscribe to use AI features.",
});
}
if (customerRecord.credits < amount) {
throw new HttpException(HttpStatusCode.PAYMENT_REQUIRED, {
code: "error.insufficientCredits",
message: "Insufficient credits. Please add more credits to continue.",
});
}
const newBalance = customerRecord.credits - amount;
// Deduct credits and log transaction
await db.transaction(async (tx) => {
await tx
.update(customer)
.set({
credits: sql`${customer.credits} - ${amount}`,
})
.where(eq(customer.id, customerRecord.id));
await tx.insert(creditTransaction).values({
id: generateId(),
customerId: customerRecord.id,
amount: -amount,
type: "usage",
reason: feature ?? "AI feature usage",
balanceAfter: newBalance,
metadata: JSON.stringify({
endpoint: c.req.path,
feature,
}),
});
});
await next();
});

View 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,
};
});
};

View 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,
};
};

View 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);
},
);

View 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));

View 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
);
};

View 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"),
}),
),
);

View 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 });
});

View 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));

View 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,
};
});
};

View 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") })),
);

View 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);

View 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);

View 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);

View 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)));

View 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;
}
});

View 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);
});

View 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],
},
);
});

View 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));

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"),
}),
),
);

View 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(),
},
});
},
);

View File

@@ -0,0 +1,385 @@
import * as z from "zod";
import {
InvitationStatus,
MemberRole,
SocialProvider,
UserRole,
} from "@turbostarter/auth";
import { BillingStatus, PricingPlanType } from "@turbostarter/billing";
import { updateCustomerSchema } from "@turbostarter/db/schema";
import {
offsetPaginationSchema,
sortSchema,
} from "@turbostarter/shared/schema";
export const getUsersInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
q: z.string().optional(),
role: z
.union([
z.enum(UserRole).transform((val) => [val]),
z.array(z.enum(UserRole)),
])
.optional(),
twoFactorEnabled: z
.union([
z.coerce.boolean().transform((val) => [val]),
z.array(z.coerce.boolean()),
])
.optional(),
banned: z
.union([
z.coerce.boolean().transform((val) => [val]),
z.array(z.coerce.boolean()),
])
.optional(),
createdAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(),
});
export type GetUsersInput = z.infer<typeof getUsersInputSchema>;
export const getUsersResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
email: z.string(),
emailVerified: z.boolean(),
name: z.string(),
image: z.string().nullish(),
twoFactorEnabled: z.boolean().nullable(),
isAnonymous: z.boolean(),
banned: z.boolean().nullable(),
role: z.string().nullish(),
banReason: z.string().nullish(),
banExpires: z.coerce.date().nullish(),
}),
),
total: z.number(),
});
export type GetUsersResponse = z.infer<typeof getUsersResponseSchema>;
export const getUserAccountsInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
providerId: z
.union([
z
.enum(["credential", ...Object.values(SocialProvider)])
.transform((val) => [val]),
z.array(z.enum(["credential", ...Object.values(SocialProvider)])),
])
.optional(),
createdAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(),
updatedAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(),
});
export type GetUserAccountsInput = z.infer<typeof getUserAccountsInputSchema>;
export const getUserPlansInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
plan: z
.union([
z.enum(PricingPlanType).transform((val) => [val]),
z.array(z.enum(PricingPlanType)),
])
.nullable()
.optional(),
status: z
.union([
z.enum(BillingStatus).transform((val) => [val]),
z.array(z.enum(BillingStatus)),
])
.nullable()
.optional(),
createdAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(),
});
export type GetUserPlansInput = z.infer<typeof getUserPlansInputSchema>;
export const getUserPlansResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
userId: z.string(),
customerId: z.string(),
plan: z.enum(PricingPlanType).nullable(),
status: z.enum(BillingStatus).nullable(),
credits: z.number(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
user: z.object({
name: z.string(),
}),
}),
),
total: z.number(),
});
export type GetUserPlansResponse = z.infer<typeof getUserPlansResponseSchema>;
export const getUserAccountsResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
userId: z.string(),
providerId: z.string(),
accountId: z.string(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
}),
),
total: z.number(),
});
export type GetUserAccountsResponse = z.infer<
typeof getUserAccountsResponseSchema
>;
export const getUserMembershipsInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
role: z
.union([
z.enum(MemberRole).transform((val) => [val]),
z.array(z.enum(MemberRole)),
])
.optional(),
createdAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(),
});
export type GetUserMembershipsInput = z.infer<
typeof getUserMembershipsInputSchema
>;
export const getUserMembershipsResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
organizationId: z.string(),
role: z.enum(MemberRole),
createdAt: z.coerce.date(),
userId: z.string(),
organization: z.object({
id: z.string(),
name: z.string(),
slug: z.string().nullish(),
logo: z.string().nullish(),
}),
user: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
}),
),
total: z.number(),
});
export type GetUserMembershipsResponse = z.infer<
typeof getUserMembershipsResponseSchema
>;
export const getUserInvitationsInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
role: z
.union([
z.enum(MemberRole).transform((val) => [val]),
z.array(z.enum(MemberRole)),
])
.optional(),
status: z
.union([
z.enum(InvitationStatus).transform((val) => [val]),
z.array(z.enum(InvitationStatus)),
])
.optional(),
expiresAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(),
});
export type GetUserInvitationsInput = z.infer<
typeof getUserInvitationsInputSchema
>;
export const getUserInvitationsResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
email: z.string(),
role: z.enum(MemberRole),
status: z.enum(InvitationStatus),
expiresAt: z.coerce.date(),
createdAt: z.coerce.date(),
inviterId: z.string(),
organizationId: z.string(),
organization: z.object({
id: z.string(),
name: z.string(),
logo: z.string().nullish(),
}),
}),
),
total: z.number(),
});
export type GetUserInvitationsResponse = z.infer<
typeof getUserInvitationsResponseSchema
>;
export const getOrganizationsInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
q: z.string().optional(),
createdAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(),
members: z.tuple([z.coerce.number(), z.coerce.number()]).optional(),
});
export type GetOrganizationsInput = z.infer<typeof getOrganizationsInputSchema>;
export const getOrganizationsResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
name: z.string(),
slug: z.string().nullish(),
logo: z.string().nullish(),
createdAt: z.coerce.date(),
members: z.number(),
}),
),
total: z.number(),
max: z.object({
members: z.number(),
}),
});
export type GetOrganizationsResponse = z.infer<
typeof getOrganizationsResponseSchema
>;
export const getOrganizationResponseSchema = z
.object({
id: z.string(),
name: z.string(),
slug: z.string().nullish(),
logo: z.string().nullish(),
createdAt: z.coerce.date(),
})
.nullable();
export type GetOrganizationResponse = z.infer<
typeof getOrganizationResponseSchema
>;
export const getCustomersInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
q: z.string().optional(),
plan: z
.union([
z.enum(PricingPlanType).transform((val) => [val]),
z.array(z.enum(PricingPlanType)),
])
.optional(),
status: z
.union([
z.enum(BillingStatus).transform((val) => [val]),
z.array(z.enum(BillingStatus)),
])
.optional(),
createdAt: z.tuple([z.coerce.number(), z.coerce.number()]).optional(),
});
export type GetCustomersInput = z.infer<typeof getCustomersInputSchema>;
export const getCustomersResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
customerId: z.string(),
userId: z.string(),
plan: z.enum(PricingPlanType).nullable(),
status: z.enum(BillingStatus).nullable(),
credits: z.number(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
user: z.object({
name: z.string(),
image: z.string().nullish(),
}),
}),
),
total: z.number(),
});
export type GetCustomersResponse = z.infer<typeof getCustomersResponseSchema>;
export { updateCustomerSchema as updateCustomerInputSchema };
export type UpdateCustomerInput = z.infer<typeof updateCustomerSchema>;
// ============================================================================
// Credit Management Schemas
// ============================================================================
export const updateCreditsSchema = z.object({
action: z.enum(["set", "add", "deduct"]),
amount: z.number().int().positive(),
reason: z.string().max(500).optional(),
});
export type UpdateCreditsInput = z.infer<typeof updateCreditsSchema>;
export const getTransactionsSchema = z.object({
customerId: z.string(),
page: z.number().int().positive().default(1),
perPage: z.number().int().positive().max(100).default(20),
type: z
.enum([
"signup",
"purchase",
"usage",
"admin_grant",
"admin_deduct",
"refund",
"promo",
"referral",
"expiry",
])
.optional(),
});
export type GetTransactionsInput = z.infer<typeof getTransactionsSchema>;

View File

@@ -0,0 +1,2 @@
export * from "./admin";
export * from "./organization";

View File

@@ -0,0 +1,95 @@
import * as z from "zod";
import { InvitationStatus, MemberRole } from "@turbostarter/auth";
import {
offsetPaginationSchema,
sortSchema,
} from "@turbostarter/shared/schema";
export const getMembersInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
q: z.string().optional(),
role: z
.union([
z.enum(MemberRole).transform((val) => [val]),
z.array(z.enum(MemberRole)),
])
.optional(),
createdAt: z.tuple([z.number(), z.number()]).optional(),
});
export type GetMembersInput = z.infer<typeof getMembersInputSchema>;
export const getMembersResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
organizationId: z.string(),
role: z.enum(MemberRole),
createdAt: z.coerce.date(),
userId: z.string(),
user: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
image: z
.string()
.nullish()
.transform((val) => (val === null ? undefined : val)),
}),
}),
),
total: z.number(),
});
export type GetMembersResponse = z.infer<typeof getMembersResponseSchema>;
export const getInvitationsInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
email: z.string().optional(),
role: z
.union([
z.enum(MemberRole).transform((val) => [val]),
z.array(z.enum(MemberRole)),
])
.optional(),
status: z
.union([
z.enum(InvitationStatus).transform((val) => [val]),
z.array(z.enum(InvitationStatus)),
])
.optional(),
expiresAt: z.tuple([z.number(), z.number()]).optional(),
});
export type GetInvitationsInput = z.infer<typeof getInvitationsInputSchema>;
export const getInvitationsResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
organizationId: z.string(),
email: z.string(),
role: z.enum(MemberRole),
expiresAt: z.coerce.date(),
createdAt: z.coerce.date(),
inviterId: z.string(),
status: z.enum(InvitationStatus),
}),
),
total: z.number(),
});
export type GetInvitationsResponse = z.infer<
typeof getInvitationsResponseSchema
>;

View File

@@ -0,0 +1,111 @@
import * as z from "zod";
import type { ClientRequestOptions } from "hono";
import type { ClientResponse } from "hono/client";
type HandleReturn<
T,
E extends boolean,
S extends z.ZodType | undefined = undefined,
> = E extends true
? S extends z.ZodType
? z.infer<S>
: T
: S extends z.ZodType
? z.infer<S> | null
: T | null;
const apiErrorSchema = z.object({
code: z.string().optional(),
message: z.string(),
timestamp: z.string(),
path: z.string(),
});
export const isAPIError = (e: unknown): e is z.infer<typeof apiErrorSchema> => {
return apiErrorSchema.safeParse(e).success;
};
interface HandleOptions<
E extends boolean = true,
S extends z.ZodType | undefined = undefined,
> {
throwOnError?: E;
schema?: S;
}
export const handle = <
TResponse,
TArgs,
E extends boolean = true,
S extends z.ZodType | undefined = undefined,
>(
fn: (
args: TArgs,
options?: ClientRequestOptions,
) => Promise<ClientResponse<TResponse, number, "json">>,
options: HandleOptions<E, S> = {},
) => {
const { throwOnError = true as E, schema } = options;
const handler = async (
args?: TArgs,
requestOptions?: ClientRequestOptions,
): Promise<HandleReturn<TResponse, E, S>> => {
const response = await fn(args as TArgs, requestOptions);
let data: unknown;
try {
data = await response.json();
} catch (e) {
if (throwOnError) {
throw new Error(
e instanceof Error
? e.message
: "Something went wrong. Please try again later.",
);
}
return null as HandleReturn<TResponse, E, S>;
}
if (!response.ok) {
if (throwOnError) {
throw new Error(
isAPIError(data)
? data.message
: "Something went wrong. Please try again later.",
);
}
return null as HandleReturn<TResponse, E, S>;
}
if (schema) {
const result = schema.safeParse(data);
if (!result.success) {
if (throwOnError) {
throw result.error;
}
return null as HandleReturn<TResponse, E, S>;
}
return result.data as HandleReturn<TResponse, E, S>;
}
return data as HandleReturn<TResponse, E, S>;
};
return Object.assign(handler, {
__responseType: {} as HandleReturn<TResponse, E, S>,
});
};
export const withTimeout = <T>(
promise: Promise<T>,
timeoutMillis: number,
): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("Request timed out!")), timeoutMillis),
),
]);
};

View File

@@ -0,0 +1,76 @@
import * as z from "zod";
import { isKey } from "@turbostarter/i18n";
import { getTranslation } from "@turbostarter/i18n/server";
import { captureException } from "@turbostarter/monitoring-web/server";
import { HttpStatusCode } from "@turbostarter/shared/constants";
import { logger } from "@turbostarter/shared/logger";
import { getStatusCode } from "@turbostarter/shared/utils";
import type { Context } from "hono";
const errorSchema = z.object({
code: z.string().optional(),
message: z.string(),
});
const isError = (e: unknown): e is z.infer<typeof errorSchema> => {
return errorSchema.safeParse(e).success;
};
export const onError = async (
e: unknown,
c?: Context<{
Bindings: { NODE_ENV: string };
Variables: { locale: string };
}>,
) => {
const { t, i18n } = await getTranslation({
locale: c?.var.locale,
request: c?.req.raw,
});
const status = getStatusCode(e);
const details = {
status,
headers: {
"Content-Type": "application/json",
},
};
const timestamp = new Date().toISOString();
const path = c?.req.raw.url ? new URL(c.req.raw.url).pathname : "/api";
if (status >= HttpStatusCode.INTERNAL_SERVER_ERROR) {
captureException(e, { path, status, timestamp });
}
if (isError(e)) {
logger.error(e.code, e.message);
return new Response(
JSON.stringify({
code: e.code,
message: e.message
? e.message
: e.code && isKey(e.code, i18n)
? t(e.code)
: ((e.message || e.code) ?? t("common:error.general")),
status,
timestamp,
path,
}),
details,
);
}
logger.error(e);
return new Response(
JSON.stringify({
code: "common:error.general",
message: t("common:error.general"),
status,
path,
}),
details,
);
};

View File

@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { handle, isAPIError, withTimeout } from "../index";
describe("isAPIError", () => {
it("should return true for valid API error object", () => {
const error = {
code: "ERROR_CODE",
message: "Something went wrong",
timestamp: new Date().toISOString(),
path: "/api/test",
};
expect(isAPIError(error)).toBe(true);
});
it.each([
[{}],
[{ code: "ERROR_CODE" }],
[{ message: "Something went wrong" }],
[{ timestamp: new Date().toISOString() }],
[{ path: "/api/test" }],
])("should return false for invalid object", (input) => {
expect(isAPIError(input)).toBe(false);
});
it.each([[null], [undefined]])("should return false for %s", (input) => {
expect(isAPIError(input)).toBe(false);
});
});
describe("handle", () => {
it("should return data on success", async () => {
const mockFn = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const handler = handle(mockFn);
const result = await handler({});
expect(result).toEqual({ success: true });
});
it("should throw error on failure if throwOnError is true (default)", async () => {
const mockFn = vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ message: "Failed" }),
});
const handler = handle(mockFn);
await expect(handler({})).rejects.toThrow(Error);
});
it("should return null on failure if throwOnError is false", async () => {
const mockFn = vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ message: "Failed" }),
});
const handler = handle(mockFn, { throwOnError: false });
const result = await handler({});
expect(result).toBeNull();
});
it("should throw error if fetch throws and throwOnError is true", async () => {
const mockFn = vi.fn().mockRejectedValue(new Error("Network Error"));
const handler = handle(mockFn);
await expect(handler({})).rejects.toThrow("Network Error");
});
it("should allow error propagation if fetch throws and throwOnError is false", async () => {
const mockFn = vi.fn().mockRejectedValue(new Error("Network Error"));
const handler = handle(mockFn, { throwOnError: false });
// Expect it to throw because handle doesn't catch fn() errors
await expect(handler({})).rejects.toThrow("Network Error");
});
it("should validate data with schema if provided", async () => {
const schema = z.object({ id: z.number() });
const mockFn = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 123 }),
});
const handler = handle(mockFn, { schema });
const result = await handler({});
expect(result).toEqual({ id: 123 });
});
it("should throw error if schema validation fails and throwOnError is true", async () => {
const schema = z.object({ id: z.number() });
const mockFn = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: "invalid" }),
});
const handler = handle(mockFn, { schema });
await expect(handler({})).rejects.toThrow();
});
it("should return null if schema validation fails and throwOnError is false", async () => {
const schema = z.object({ id: z.number() });
const mockFn = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: "invalid" }),
});
const handler = handle(mockFn, { schema, throwOnError: false });
const result = await handler({});
expect(result).toBeNull();
});
});
describe("withTimeout", () => {
it("should resolve if promise finishes before timeout", async () => {
const promise = new Promise((resolve) =>
setTimeout(() => resolve("done"), 10),
);
const result = await withTimeout(promise, 100);
expect(result).toBe("done");
});
it("should reject if timeout is reached", async () => {
const promise = new Promise((resolve) =>
setTimeout(() => resolve("done"), 100),
);
await expect(withTimeout(promise, 10)).rejects.toThrow(Error);
});
});

View File

@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { onError } from "../on-error";
import type { Context } from "hono";
vi.mock("@turbostarter/i18n/server", () => ({
getTranslation: vi.fn().mockResolvedValue({
t: (key: string) => key,
i18n: {},
}),
}));
vi.mock("@turbostarter/i18n", () => ({
isKey: vi.fn().mockReturnValue(true),
}));
vi.mock("@turbostarter/shared/utils", () => ({
getStatusCode: vi.fn().mockReturnValue(500),
}));
vi.mock("@turbostarter/monitoring-web/server", () => ({
captureException: vi.fn(),
}));
interface ErrorResponse {
code: string;
message: string;
status: number;
path: string;
timestamp?: string;
}
describe("onError", () => {
beforeEach(() => {
vi.spyOn(console, "log").mockImplementation(() => {
/* empty */
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should return a formatted error response for valid error object", async () => {
const error = {
code: "ERROR_CODE",
message: "Something went wrong",
};
const context = {
req: { raw: { url: "http://localhost/api/test" } },
var: { locale: "en" },
} as Context<{
Bindings: { NODE_ENV: string };
Variables: { locale: string };
}>;
const response = await onError(error, context);
const data = (await response.json()) as ErrorResponse;
expect(data).toMatchObject({
code: "ERROR_CODE",
message: "Something went wrong",
status: 500,
path: "/api/test",
});
expect(data.timestamp).toBeDefined();
});
it("should translate error code if message is missing", async () => {
const error = {
code: "auth.error.invalid_credentials",
message: "",
};
const context = {
req: { raw: { url: "http://localhost/api/test" } },
var: { locale: "en" },
} as Context<{
Bindings: { NODE_ENV: string };
Variables: { locale: string };
}>;
const response = await onError(error, context);
const data = (await response.json()) as ErrorResponse;
expect(data.message).toBe("auth.error.invalid_credentials");
});
it("should fallback to general error if input is not a recognized error object", async () => {
const error = "Just a string error";
const context = {
req: { raw: { url: "http://localhost/api/test" } },
var: { locale: "en" },
} as Context<{
Bindings: { NODE_ENV: string };
Variables: { locale: string };
}>;
const response = await onError(error, context);
const data = (await response.json()) as ErrorResponse;
expect(data).toMatchObject({
code: "common:error.general",
message: "common:error.general",
status: 500,
path: "/api/test",
});
});
});