feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user