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

1
packages/api/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
canvas.old/

View File

@@ -0,0 +1,3 @@
import baseConfig from "@turbostarter/eslint-config/base";
export default baseConfig;

48
packages/api/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "@turbostarter/api",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./utils": "./src/utils/index.ts",
"./schema": "./src/schema/index.ts"
},
"scripts": {
"clean": "git clean -xdf .cache .turbo dist node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"prettier": "@turbostarter/prettier-config",
"dependencies": {
"@ai-sdk/openai": "2.0.68",
"@anthropic-ai/sdk": "0.71.2",
"@hono/zod-validator": "0.7.4",
"@turbostarter/ai": "workspace:*",
"@turbostarter/auth": "workspace:*",
"@turbostarter/billing": "workspace:*",
"@turbostarter/db": "workspace:*",
"@turbostarter/email": "workspace:*",
"@turbostarter/i18n": "workspace:*",
"@turbostarter/monitoring-web": "workspace:*",
"@turbostarter/shared": "workspace:*",
"@turbostarter/storage": "workspace:*",
"ai": "catalog:",
"envin": "catalog:",
"hono": "4.10.4",
"zod": "catalog:"
},
"devDependencies": {
"@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

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

View File

@@ -0,0 +1,81 @@
/**
* Script to check a specific document and its embeddings
* Run: pnpm with-env npx tsx packages/api/tests/check-document.ts
*/
import { sql } from "@turbostarter/db";
import { db } from "@turbostarter/db/server";
const DOC_ID = "JnOv9Z7JK2ZWU92OrFFFuuCY1TSksaWv";
async function check() {
console.log(`\n=== Checking document: ${DOC_ID} ===\n`);
// 1. Check if document exists in pdf.document
const docResult = await db.execute<{
id: string;
path: string;
name: string | null;
chat_id: string;
created_at: Date;
}>(sql`
SELECT id, path, name, chat_id, created_at
FROM pdf.document
WHERE id = ${DOC_ID}
`);
console.log("Document record:", docResult);
if (!Array.isArray(docResult) || docResult.length === 0) {
console.log("❌ Document NOT FOUND in pdf.document table!");
// Check if it might be in a different table or with different ID
const allDocs = await db.execute<{
id: string;
path: string;
chat_id: string;
}>(sql`SELECT id, path, chat_id FROM pdf.document ORDER BY created_at DESC LIMIT 10`);
console.log("\nRecent documents in pdf.document:");
for (const doc of allDocs as { id: string; path: string }[]) {
console.log(` - ${doc.id} | ${doc.path}`);
}
process.exit(0);
}
const doc = docResult[0]!;
console.log(`✅ Document found: ${doc.name ?? doc.path}`);
// 2. Count embeddings for this document
const embeddingCount = await db.execute<{ count: number }>(sql`
SELECT COUNT(*)::int as count
FROM pdf.embedding
WHERE document_id = ${DOC_ID}
`);
const count = (embeddingCount as { count: number }[])[0]?.count ?? 0;
console.log(`📊 Embedding count: ${count}`);
if (count === 0) {
console.log("❌ Document has 0 embeddings - needs regeneration!");
console.log(` Path: ${doc.path}`);
} else {
console.log("✅ Document has embeddings");
// Show sample embeddings
const samples = await db.execute(sql`
SELECT id, LEFT(content, 80) as preview, page_number
FROM pdf.embedding
WHERE document_id = ${DOC_ID}
LIMIT 3
`);
console.log("\nSample embeddings:", samples);
}
process.exit(0);
}
check().catch((e) => {
console.error("Error:", e);
process.exit(1);
});

View File

@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest";
import { updateCreditsSchema, getTransactionsSchema } from "../../src/schema/admin";
describe("updateCreditsSchema", () => {
describe("action field", () => {
it("should accept 'set' action", () => {
const result = updateCreditsSchema.safeParse({
action: "set",
amount: 100,
});
expect(result.success).toBe(true);
});
it("should accept 'add' action", () => {
const result = updateCreditsSchema.safeParse({
action: "add",
amount: 100,
});
expect(result.success).toBe(true);
});
it("should accept 'deduct' action", () => {
const result = updateCreditsSchema.safeParse({
action: "deduct",
amount: 100,
});
expect(result.success).toBe(true);
});
it("should reject invalid action", () => {
const result = updateCreditsSchema.safeParse({
action: "invalid",
amount: 100,
});
expect(result.success).toBe(false);
});
});
describe("amount field", () => {
it("should accept positive integers", () => {
const result = updateCreditsSchema.safeParse({
action: "add",
amount: 500,
});
expect(result.success).toBe(true);
});
it("should reject zero", () => {
const result = updateCreditsSchema.safeParse({
action: "add",
amount: 0,
});
expect(result.success).toBe(false);
});
it("should reject negative numbers", () => {
const result = updateCreditsSchema.safeParse({
action: "add",
amount: -100,
});
expect(result.success).toBe(false);
});
it("should reject non-integers", () => {
const result = updateCreditsSchema.safeParse({
action: "add",
amount: 10.5,
});
expect(result.success).toBe(false);
});
});
describe("reason field", () => {
it("should accept optional reason", () => {
const result = updateCreditsSchema.safeParse({
action: "add",
amount: 100,
reason: "Promotional credit",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBe("Promotional credit");
}
});
it("should accept missing reason", () => {
const result = updateCreditsSchema.safeParse({
action: "add",
amount: 100,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBeUndefined();
}
});
it("should reject reason over 500 characters", () => {
const result = updateCreditsSchema.safeParse({
action: "add",
amount: 100,
reason: "a".repeat(501),
});
expect(result.success).toBe(false);
});
});
});
describe("getTransactionsSchema", () => {
it("should require customerId", () => {
const result = getTransactionsSchema.safeParse({
page: 1,
perPage: 20,
});
expect(result.success).toBe(false);
});
it("should accept valid input with defaults", () => {
const result = getTransactionsSchema.safeParse({
customerId: "cust-123",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.page).toBe(1);
expect(result.data.perPage).toBe(20);
}
});
it("should accept pagination parameters", () => {
const result = getTransactionsSchema.safeParse({
customerId: "cust-123",
page: 3,
perPage: 50,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.page).toBe(3);
expect(result.data.perPage).toBe(50);
}
});
it("should reject perPage over 100", () => {
const result = getTransactionsSchema.safeParse({
customerId: "cust-123",
perPage: 150,
});
expect(result.success).toBe(false);
});
describe("type filter", () => {
const validTypes = [
"signup",
"purchase",
"usage",
"admin_grant",
"admin_deduct",
"refund",
"promo",
"referral",
"expiry",
] as const;
validTypes.forEach((type) => {
it(`should accept type '${type}'`, () => {
const result = getTransactionsSchema.safeParse({
customerId: "cust-123",
type,
});
expect(result.success).toBe(true);
});
});
it("should reject invalid type", () => {
const result = getTransactionsSchema.safeParse({
customerId: "cust-123",
type: "invalid",
});
expect(result.success).toBe(false);
});
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import {
Credits,
hasEnoughCredits,
getCreditsLevel,
getCreditsProgress,
} from "@turbostarter/ai/credits/utils";
describe("Credits constants", () => {
it("should have default balance of 100", () => {
expect(Credits.BALANCE).toBe(100);
});
it("should have FREE cost of 0", () => {
expect(Credits.COST.FREE).toBe(0);
});
it("should have DEFAULT cost of 5", () => {
expect(Credits.COST.DEFAULT).toBe(5);
});
it("should have HIGH cost of 10", () => {
expect(Credits.COST.HIGH).toBe(10);
});
});
describe("hasEnoughCredits", () => {
it("should return true when credits are sufficient", () => {
expect(hasEnoughCredits(100, 50)).toBe(true);
expect(hasEnoughCredits(50, 50)).toBe(true);
expect(hasEnoughCredits(100, 0)).toBe(true);
});
it("should return false when credits are insufficient", () => {
expect(hasEnoughCredits(50, 100)).toBe(false);
expect(hasEnoughCredits(0, 1)).toBe(false);
expect(hasEnoughCredits(99, 100)).toBe(false);
});
it("should handle edge cases", () => {
expect(hasEnoughCredits(0, 0)).toBe(true);
expect(hasEnoughCredits(1, 1)).toBe(true);
});
});
describe("getCreditsLevel", () => {
it("should return 'high' when credits > 50%", () => {
expect(getCreditsLevel(60, 100)).toBe("high");
expect(getCreditsLevel(100, 100)).toBe("high");
expect(getCreditsLevel(51, 100)).toBe("high");
});
it("should return 'medium' when credits between 15% and 50%", () => {
expect(getCreditsLevel(50, 100)).toBe("medium");
expect(getCreditsLevel(16, 100)).toBe("medium");
expect(getCreditsLevel(30, 100)).toBe("medium");
});
it("should return 'low' when credits <= 15%", () => {
expect(getCreditsLevel(15, 100)).toBe("low");
expect(getCreditsLevel(10, 100)).toBe("low");
expect(getCreditsLevel(0, 100)).toBe("low");
expect(getCreditsLevel(1, 100)).toBe("low");
});
it("should use default max of 100 when not specified", () => {
expect(getCreditsLevel(60)).toBe("high");
expect(getCreditsLevel(30)).toBe("medium");
expect(getCreditsLevel(10)).toBe("low");
});
});
describe("getCreditsProgress", () => {
it("should calculate correct progress ratio", () => {
expect(getCreditsProgress(50, 100)).toBe(0.5);
expect(getCreditsProgress(25, 100)).toBe(0.25);
expect(getCreditsProgress(100, 100)).toBe(1);
expect(getCreditsProgress(0, 100)).toBe(0);
});
it("should use default max of 100", () => {
expect(getCreditsProgress(50)).toBe(0.5);
expect(getCreditsProgress(100)).toBe(1);
});
it("should handle custom max values", () => {
expect(getCreditsProgress(500, 1000)).toBe(0.5);
expect(getCreditsProgress(250, 500)).toBe(0.5);
});
});

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the database module before importing the mutation
vi.mock("@turbostarter/db/server", () => ({
db: {
transaction: vi.fn(),
},
}));
vi.mock("@turbostarter/shared/utils", () => ({
generateId: vi.fn(() => "test-transaction-id"),
HttpException: class HttpException extends Error {
constructor(public statusCode: number, public body?: { code: string; message?: string }) {
super(body?.message ?? "HttpException");
}
},
}));
import { db } from "@turbostarter/db/server";
import { updateCustomerCredits } from "../../src/modules/admin/customers/mutations";
describe("updateCustomerCredits", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("action: add", () => {
it("should add credits to customer balance", async () => {
const mockCustomer = { id: "cust-1", credits: 100 };
const mockTx = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([mockCustomer]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/unbound-method
vi.mocked(db.transaction).mockImplementation((callback) => callback(mockTx as any));
const result = await updateCustomerCredits(
"cust-1",
{ action: "add", amount: 50 },
"admin-1"
);
expect(result).toEqual({
previousBalance: 100,
newBalance: 150,
action: "add",
amount: 50,
});
expect(mockTx.insert).toHaveBeenCalled();
});
});
describe("action: deduct", () => {
it("should deduct credits from customer balance", async () => {
const mockCustomer = { id: "cust-1", credits: 100 };
const mockTx = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([mockCustomer]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/unbound-method
vi.mocked(db.transaction).mockImplementation((callback) => callback(mockTx as any));
const result = await updateCustomerCredits(
"cust-1",
{ action: "deduct", amount: 30 },
"admin-1"
);
expect(result).toEqual({
previousBalance: 100,
newBalance: 70,
action: "deduct",
amount: 30,
});
});
it("should throw error when deducting more than available", async () => {
const mockCustomer = { id: "cust-1", credits: 20 };
const mockTx = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([mockCustomer]),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/unbound-method
vi.mocked(db.transaction).mockImplementation((callback) => callback(mockTx as any));
await expect(
updateCustomerCredits("cust-1", { action: "deduct", amount: 50 }, "admin-1")
).rejects.toThrow();
});
});
describe("action: set", () => {
it("should set credits to exact amount (increase)", async () => {
const mockCustomer = { id: "cust-1", credits: 100 };
const mockTx = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([mockCustomer]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/unbound-method
vi.mocked(db.transaction).mockImplementation((callback) => callback(mockTx as any));
const result = await updateCustomerCredits(
"cust-1",
{ action: "set", amount: 200 },
"admin-1"
);
expect(result).toEqual({
previousBalance: 100,
newBalance: 200,
action: "set",
amount: 200,
});
});
it("should set credits to exact amount (decrease)", async () => {
const mockCustomer = { id: "cust-1", credits: 100 };
const mockTx = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([mockCustomer]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/unbound-method
vi.mocked(db.transaction).mockImplementation((callback) => callback(mockTx as any));
const result = await updateCustomerCredits(
"cust-1",
{ action: "set", amount: 50 },
"admin-1"
);
expect(result).toEqual({
previousBalance: 100,
newBalance: 50,
action: "set",
amount: 50,
});
});
});
describe("error cases", () => {
it("should throw when customer not found", async () => {
const mockTx = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/unbound-method
vi.mocked(db.transaction).mockImplementation((callback) => callback(mockTx as any));
await expect(
updateCustomerCredits("nonexistent", { action: "add", amount: 100 }, "admin-1")
).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* Find the chat with the working sample PDF
* Run: pnpm with-env npx tsx packages/api/tests/find-working-chat.ts
*/
import { sql } from "@turbostarter/db";
import { db } from "@turbostarter/db/server";
async function find() {
// Get the most recent sample-local-pdf document
const doc = await db.execute<{
id: string;
chat_id: string;
path: string;
}>(sql`
SELECT id, chat_id, path
FROM pdf.document
WHERE name = 'sample-local-pdf'
ORDER BY created_at DESC
LIMIT 1
`);
const docRecord = (doc as { id: string; chat_id: string; path: string }[])[0];
if (!docRecord) {
console.log("No sample-local-pdf found");
process.exit(1);
}
console.log(`\n📄 Most recent sample-local-pdf:`);
console.log(` Document ID: ${docRecord.id}`);
console.log(` Chat ID: ${docRecord.chat_id}`);
console.log(`\n🔗 URL: /pdf/${docRecord.chat_id}`);
// Check embeddings content
const embeddings = await db.execute<{
content: string;
page_number: number;
}>(sql`
SELECT LEFT(content, 100) as content, page_number
FROM pdf.embedding
WHERE document_id = ${docRecord.id}
LIMIT 3
`);
console.log(`\n📊 Sample embedding content:`);
for (const emb of embeddings as { content: string; page_number: number }[]) {
console.log(` Page ${emb.page_number}: "${emb.content.replace(/\n/g, " ")}"`);
}
process.exit(0);
}
find().catch((e) => {
console.error("Error:", e);
process.exit(1);
});

View File

@@ -0,0 +1,99 @@
/**
* Script to fix embeddings for a specific document with corrupted embeddings
* Run: pnpm with-env npx tsx packages/api/tests/fix-document-embeddings.ts
*/
import { generateDocumentEmbeddings } from "@turbostarter/ai/pdf/embeddings";
import { sql } from "@turbostarter/db";
import { pdfEmbedding } from "@turbostarter/db/schema/pdf";
import { db } from "@turbostarter/db/server";
const DOC_ID = "JnOv9Z7JK2ZWU92OrFFFuuCY1TSksaWv";
async function fix() {
console.log(`\n=== Fixing embeddings for document: ${DOC_ID} ===\n`);
// 1. Get the document path
const docResult = await db.execute<{
id: string;
path: string;
name: string | null;
}>(sql`
SELECT id, path, name
FROM pdf.document
WHERE id = ${DOC_ID}
`);
const docs = Array.isArray(docResult) ? docResult : [];
if (docs.length === 0) {
console.log("❌ Document not found!");
process.exit(1);
}
const doc = docs[0]!;
console.log(`📄 Document: ${doc.name ?? doc.id}`);
console.log(`📁 Path: ${doc.path}`);
// 2. Delete existing (corrupted) embeddings
console.log("\n🗑 Deleting corrupted embeddings...");
await db.execute(sql`
DELETE FROM pdf.embedding
WHERE document_id = ${DOC_ID}
`);
console.log(" Done.");
// 3. Regenerate embeddings from the actual PDF
console.log("\n📊 Generating new embeddings from PDF...");
try {
const generated = await generateDocumentEmbeddings(doc.path);
console.log(` Generated ${generated.length} chunks`);
if (generated.length === 0) {
console.log("⚠️ No chunks generated - PDF may be empty or unreadable");
process.exit(1);
}
// Show sample of new embeddings
console.log("\n Sample content:");
for (let i = 0; i < Math.min(3, generated.length); i++) {
const chunk = generated[i]!;
console.log(` Page ${chunk.metadata.pageNumber}: "${chunk.content.substring(0, 60)}..."`);
}
// 4. Insert new embeddings
console.log("\n💾 Inserting new embeddings...");
await db
.insert(pdfEmbedding)
.values(
generated.map((chunk) => ({
content: chunk.content,
documentId: DOC_ID,
embedding: chunk.embedding,
pageNumber: chunk.metadata.pageNumber,
charStart: chunk.metadata.charStart,
charEnd: chunk.metadata.charEnd,
sectionTitle: chunk.metadata.sectionTitle,
})),
);
console.log(` ✅ Inserted ${generated.length} embeddings`);
// 5. Verify
const countResult = await db.execute<{ count: number }>(sql`
SELECT COUNT(*)::int as count
FROM pdf.embedding
WHERE document_id = ${DOC_ID}
`);
const count = (countResult as { count: number }[])[0]?.count ?? 0;
console.log(`\n✅ Verification: Document now has ${count} embeddings`);
} catch (error) {
console.error("\n❌ Error generating embeddings:", error);
process.exit(1);
}
process.exit(0);
}
fix().catch((e) => {
console.error("Fatal error:", e);
process.exit(1);
});

View File

@@ -0,0 +1,39 @@
/**
* List recent documents and their embedding counts
* Run: pnpm with-env npx tsx packages/api/tests/list-documents.ts
*/
import { sql } from "@turbostarter/db";
import { db } from "@turbostarter/db/server";
async function list() {
const docs = await db.execute<{
id: string;
name: string | null;
path: string;
embedding_count: number;
created_at: Date;
}>(sql`
SELECT d.id, d.name, d.path, d.created_at,
(SELECT COUNT(*)::int FROM pdf.embedding e WHERE e.document_id = d.id) as embedding_count
FROM pdf.document d
ORDER BY d.created_at DESC
LIMIT 10
`);
console.log("\nRecent documents:\n");
for (const doc of docs as { id: string; name: string | null; path: string; embedding_count: number; created_at: Date }[]) {
console.log(` 📄 ${doc.name ?? "unnamed"}`);
console.log(` ID: ${doc.id}`);
console.log(` Path: ${doc.path}`);
console.log(` Embeddings: ${doc.embedding_count}`);
console.log(` Created: ${String(doc.created_at)}`);
console.log("");
}
process.exit(0);
}
list().catch((e) => {
console.error("Error:", e);
process.exit(1);
});

View File

@@ -0,0 +1,63 @@
/**
* Script to regenerate embeddings for documents with 0 embeddings
* Run: pnpm with-env npx tsx packages/api/tests/regenerate-embeddings.ts
*/
import { generateDocumentEmbeddings } from "@turbostarter/ai/pdf/embeddings";
import { sql } from "@turbostarter/db";
import { pdfEmbedding } from "@turbostarter/db/schema/pdf";
import { db } from "@turbostarter/db/server";
async function regenerate() {
// Find documents with 0 embeddings
const orphans = await db.execute<{
id: string;
path: string;
chat_id: string;
}>(sql`
SELECT d.id, d.path, d.chat_id
FROM pdf.document d
WHERE NOT EXISTS (
SELECT 1 FROM pdf.embedding e WHERE e.document_id = d.id
)
`);
console.log(`Found ${orphans.length} documents without embeddings`);
for (const doc of orphans) {
console.log(`\nProcessing document: ${doc.id}`);
console.log(` Path: ${doc.path}`);
try {
const generated = await generateDocumentEmbeddings(doc.path);
console.log(` Generated ${generated.length} chunks`);
if (generated.length > 0) {
await db
.insert(pdfEmbedding)
.values(
generated.map((chunk) => ({
content: chunk.content,
documentId: doc.id,
embedding: chunk.embedding,
pageNumber: chunk.metadata.pageNumber,
charStart: chunk.metadata.charStart,
charEnd: chunk.metadata.charEnd,
sectionTitle: chunk.metadata.sectionTitle,
})),
)
.onConflictDoNothing();
console.log(` ✅ Inserted embeddings`);
}
} catch (error) {
console.error(` ❌ Error:`, error instanceof Error ? error.message : error);
}
}
console.log("\nDone!");
process.exit(0);
}
regenerate().catch((e) => {
console.error("Fatal error:", e);
process.exit(1);
});

View File

@@ -0,0 +1,9 @@
{
"extends": "@turbostarter/tsconfig/internal.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "ES2022"],
"jsx": "preserve"
},
"include": ["*.ts", "src/**/*", "tests/**/*"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,3 @@
import baseConfig from "@turbostarter/vitest-config/base";
export default baseConfig;