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