fix: OIDC auth flow, Vite proxy, and PostgreSQL query compat
- Add auth redirect in root layout for unauthenticated users - Proxy OIDC routes (/login, /callback, /logout) through Vite dev server - Strip Secure flag from OIDC cookies in dev mode (HTTP localhost) - Disable retry on auth query to prevent stale cookie loops - Fix SQLite .get()/.all()/.run() calls in category and global-item services for PostgreSQL compatibility - Add userId scoping to category service functions - Add OIDC error logging in auth middleware - Apply linter auto-formatting across affected files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ export function useAuth() {
|
||||
queryKey: ["auth"],
|
||||
queryFn: () => apiGet<AuthState>("/api/auth/me"),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,12 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as GlobalItemsIndexRouteImport } from './routes/global-items/index'
|
||||
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
|
||||
import { Route as UsersUserIdRouteImport } from './routes/users/$userId'
|
||||
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
||||
import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId'
|
||||
import { Route as GlobalItemsGlobalItemIdRouteImport } from './routes/global-items/$globalItemId'
|
||||
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
@@ -31,11 +34,21 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const GlobalItemsIndexRoute = GlobalItemsIndexRouteImport.update({
|
||||
id: '/global-items/',
|
||||
path: '/global-items/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CollectionIndexRoute = CollectionIndexRouteImport.update({
|
||||
id: '/collection/',
|
||||
path: '/collection/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const UsersUserIdRoute = UsersUserIdRouteImport.update({
|
||||
id: '/users/$userId',
|
||||
path: '/users/$userId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ThreadsThreadIdRoute = ThreadsThreadIdRouteImport.update({
|
||||
id: '/threads/$threadId',
|
||||
path: '/threads/$threadId',
|
||||
@@ -46,31 +59,45 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({
|
||||
path: '/setups/$setupId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({
|
||||
id: '/global-items/$globalItemId',
|
||||
path: '/global-items/$globalItemId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
'/users/$userId': typeof UsersUserIdRoute
|
||||
'/collection/': typeof CollectionIndexRoute
|
||||
'/global-items/': typeof GlobalItemsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
'/users/$userId': typeof UsersUserIdRoute
|
||||
'/collection': typeof CollectionIndexRoute
|
||||
'/global-items': typeof GlobalItemsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
|
||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
'/users/$userId': typeof UsersUserIdRoute
|
||||
'/collection/': typeof CollectionIndexRoute
|
||||
'/global-items/': typeof GlobalItemsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -78,34 +105,46 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/global-items/$globalItemId'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
| '/users/$userId'
|
||||
| '/collection/'
|
||||
| '/global-items/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/global-items/$globalItemId'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
| '/users/$userId'
|
||||
| '/collection'
|
||||
| '/global-items'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/global-items/$globalItemId'
|
||||
| '/setups/$setupId'
|
||||
| '/threads/$threadId'
|
||||
| '/users/$userId'
|
||||
| '/collection/'
|
||||
| '/global-items/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
GlobalItemsGlobalItemIdRoute: typeof GlobalItemsGlobalItemIdRoute
|
||||
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
||||
UsersUserIdRoute: typeof UsersUserIdRoute
|
||||
CollectionIndexRoute: typeof CollectionIndexRoute
|
||||
GlobalItemsIndexRoute: typeof GlobalItemsIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -131,6 +170,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/global-items/': {
|
||||
id: '/global-items/'
|
||||
path: '/global-items'
|
||||
fullPath: '/global-items/'
|
||||
preLoaderRoute: typeof GlobalItemsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/collection/': {
|
||||
id: '/collection/'
|
||||
path: '/collection'
|
||||
@@ -138,6 +184,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof CollectionIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/users/$userId': {
|
||||
id: '/users/$userId'
|
||||
path: '/users/$userId'
|
||||
fullPath: '/users/$userId'
|
||||
preLoaderRoute: typeof UsersUserIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/threads/$threadId': {
|
||||
id: '/threads/$threadId'
|
||||
path: '/threads/$threadId'
|
||||
@@ -152,6 +205,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SetupsSetupIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/global-items/$globalItemId': {
|
||||
id: '/global-items/$globalItemId'
|
||||
path: '/global-items/$globalItemId'
|
||||
fullPath: '/global-items/$globalItemId'
|
||||
preLoaderRoute: typeof GlobalItemsGlobalItemIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,9 +219,12 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
GlobalItemsGlobalItemIdRoute: GlobalItemsGlobalItemIdRoute,
|
||||
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
||||
UsersUserIdRoute: UsersUserIdRoute,
|
||||
CollectionIndexRoute: CollectionIndexRoute,
|
||||
GlobalItemsIndexRoute: GlobalItemsIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createRootRoute,
|
||||
type ErrorComponentProps,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useMatchRoute,
|
||||
useNavigate,
|
||||
useRouter,
|
||||
@@ -72,7 +73,8 @@ function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||
|
||||
function RootLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { data: auth } = useAuth();
|
||||
const location = useLocation();
|
||||
const { data: auth, isLoading: authLoading } = useAuth();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
|
||||
// Item panel state
|
||||
@@ -99,7 +101,7 @@ function RootLayout() {
|
||||
const resolveCandidateId = useUIStore((s) => s.resolveCandidateId);
|
||||
const closeResolveDialog = useUIStore((s) => s.closeResolveDialog);
|
||||
|
||||
// Onboarding
|
||||
// Onboarding — only check when authenticated (endpoint requires auth)
|
||||
const { data: onboardingComplete, isLoading: onboardingLoading } =
|
||||
useOnboardingComplete();
|
||||
const [wizardDismissed, setWizardDismissed] = useState(false);
|
||||
@@ -152,7 +154,30 @@ function RootLayout() {
|
||||
!(collectionSearch as Record<string, string>).tab ||
|
||||
(collectionSearch as Record<string, string>).tab === "gear");
|
||||
|
||||
// Show a minimal loading state while checking onboarding status
|
||||
// Show loading while checking auth
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect unauthenticated users to login (server-side OIDC route)
|
||||
// Allow public routes through without auth
|
||||
const isPublicRoute =
|
||||
location.pathname.startsWith("/users/") || location.pathname === "/login";
|
||||
|
||||
if (!isAuthenticated && !isPublicRoute) {
|
||||
window.location.href = "/login";
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-sm text-gray-500">Redirecting to login...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading while checking onboarding status
|
||||
if (onboardingLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
|
||||
@@ -198,9 +198,7 @@ export const oauthCodes = pgTable("oauth_codes", {
|
||||
code: text("code").notNull().unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
codeChallenge: text("code_challenge").notNull(),
|
||||
codeChallengeMethod: text("code_challenge_method")
|
||||
.notNull()
|
||||
.default("S256"),
|
||||
codeChallengeMethod: text("code_challenge_method").notNull().default("S256"),
|
||||
redirectUri: text("redirect_uri").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
used: integer("used").notNull().default(0),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import seedData from "./global-items-seed.json";
|
||||
import { db as prodDb } from "./index.ts";
|
||||
import { globalItems } from "./schema.ts";
|
||||
import seedData from "./global-items-seed.json";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import {
|
||||
oidcAuthMiddleware,
|
||||
processOAuthCallback,
|
||||
revokeSession,
|
||||
} from "@hono/oidc-auth";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import { db as prodDb } from "../db/index.ts";
|
||||
import { seedDefaults } from "../db/seed.ts";
|
||||
import { mcpRoutes } from "./mcp/index.ts";
|
||||
@@ -15,8 +15,8 @@ import { categoryRoutes } from "./routes/categories.ts";
|
||||
import { imageRoutes } from "./routes/images.ts";
|
||||
import { itemRoutes } from "./routes/items.ts";
|
||||
import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts";
|
||||
import { settingsRoutes } from "./routes/settings.ts";
|
||||
import { profileRoutes } from "./routes/profiles.ts";
|
||||
import { settingsRoutes } from "./routes/settings.ts";
|
||||
import { setupRoutes } from "./routes/setups.ts";
|
||||
import { threadRoutes } from "./routes/threads.ts";
|
||||
import { totalRoutes } from "./routes/totals.ts";
|
||||
@@ -42,6 +42,24 @@ app.get("/api/health", (c) => {
|
||||
});
|
||||
|
||||
// ── OIDC Browser Auth (top-level, before /api/* middleware) ───────────
|
||||
|
||||
// In dev mode, strip Secure flag from OIDC cookies so they work over HTTP
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
app.use("*", async (c, next) => {
|
||||
await next();
|
||||
const setCookies = c.res.headers.getSetCookie?.() ?? [];
|
||||
if (setCookies.length > 0) {
|
||||
c.res.headers.delete("Set-Cookie");
|
||||
for (const cookie of setCookies) {
|
||||
c.res.headers.append(
|
||||
"Set-Cookie",
|
||||
cookie.replace(/;\s*Secure/gi, ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/login", oidcAuthMiddleware(), async (c) => c.redirect("/"));
|
||||
app.get("/callback", async (c) => processOAuthCallback(c));
|
||||
app.get("/logout", async (c) => {
|
||||
|
||||
@@ -39,7 +39,8 @@ export async function requireAuth(c: Context, next: Next) {
|
||||
c.set("userId", user.id);
|
||||
return next();
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error("[auth] OIDC auth failed:", err);
|
||||
// OIDC not configured or session invalid — fall through
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { getAuth } from "@hono/oidc-auth";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { updateProfileSchema } from "../../shared/schemas.ts";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import { requireAuth } from "../middleware/auth.ts";
|
||||
import {
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
listApiKeys,
|
||||
} from "../services/auth.service.ts";
|
||||
import { updateProfile } from "../services/profile.service.ts";
|
||||
import { updateProfileSchema } from "../../shared/schemas.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
updateSetupSchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import { withImageUrls } from "../services/storage.service.ts";
|
||||
import { getPublicSetupWithItems } from "../services/profile.service.ts";
|
||||
import {
|
||||
createSetup,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
updateItemClassification,
|
||||
updateSetup,
|
||||
} from "../services/setup.service.ts";
|
||||
import { withImageUrls } from "../services/storage.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
updateThreadSchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import {
|
||||
deleteImage,
|
||||
withImageUrls,
|
||||
} from "../services/storage.service.ts";
|
||||
import { deleteImage, withImageUrls } from "../services/storage.service.ts";
|
||||
import {
|
||||
createCandidate,
|
||||
createThread,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import type { db as prodDb } from "../../db/index.ts";
|
||||
import { apiKeys, users } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
@@ -24,11 +24,7 @@ export async function getOrCreateUser(
|
||||
|
||||
// ── API Key Management ───────────────────────────────────────────────
|
||||
|
||||
export async function createApiKey(
|
||||
db: Db,
|
||||
userId: number,
|
||||
name: string,
|
||||
) {
|
||||
export async function createApiKey(db: Db, userId: number, name: string) {
|
||||
const rawKey = randomBytes(32).toString("hex");
|
||||
const keyHash = await Bun.password.hash(rawKey);
|
||||
const keyPrefix = rawKey.slice(0, 8);
|
||||
|
||||
@@ -20,76 +20,85 @@ export async function getOrCreateUncategorized(db: Db, userId: number) {
|
||||
return created;
|
||||
}
|
||||
|
||||
export function getAllCategories(db: Db = prodDb) {
|
||||
return db.select().from(categories).orderBy(asc(categories.name)).all();
|
||||
export async function getAllCategories(db: Db, userId: number) {
|
||||
return db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.userId, userId))
|
||||
.orderBy(asc(categories.name));
|
||||
}
|
||||
|
||||
export function createCategory(
|
||||
db: Db = prodDb,
|
||||
export async function createCategory(
|
||||
db: Db,
|
||||
userId: number,
|
||||
data: { name: string; icon?: string },
|
||||
) {
|
||||
return db
|
||||
const [row] = await db
|
||||
.insert(categories)
|
||||
.values({
|
||||
name: data.name,
|
||||
userId,
|
||||
...(data.icon ? { icon: data.icon } : {}),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
export function updateCategory(
|
||||
db: Db = prodDb,
|
||||
export async function updateCategory(
|
||||
db: Db,
|
||||
userId: number,
|
||||
id: number,
|
||||
data: { name?: string; icon?: string },
|
||||
) {
|
||||
const existing = db
|
||||
const [existing] = await db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
.where(and(eq(categories.id, id), eq(categories.userId, userId)));
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
const [row] = await db
|
||||
.update(categories)
|
||||
.set(data)
|
||||
.where(eq(categories.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
.where(and(eq(categories.id, id), eq(categories.userId, userId)))
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
export function deleteCategory(
|
||||
db: Db = prodDb,
|
||||
export async function deleteCategory(
|
||||
db: Db,
|
||||
userId: number,
|
||||
id: number,
|
||||
): { success: boolean; error?: string } {
|
||||
// Guard: cannot delete Uncategorized (id=1)
|
||||
if (id === 1) {
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Check if category exists and belongs to user
|
||||
const [existing] = await db
|
||||
.select({ id: categories.id, name: categories.name })
|
||||
.from(categories)
|
||||
.where(and(eq(categories.id, id), eq(categories.userId, userId)));
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: "Category not found" };
|
||||
}
|
||||
|
||||
// Guard: cannot delete Uncategorized
|
||||
if (existing.name === "Uncategorized") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Cannot delete the Uncategorized category",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if category exists
|
||||
const existing = db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
// Get the user's Uncategorized category for reassignment
|
||||
const uncategorized = await getOrCreateUncategorized(db, userId);
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: "Category not found" };
|
||||
}
|
||||
// Reassign items to Uncategorized, then delete atomically
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(items)
|
||||
.set({ categoryId: uncategorized.id })
|
||||
.where(eq(items.categoryId, id));
|
||||
|
||||
// Reassign items to Uncategorized (id=1), then delete atomically
|
||||
db.transaction(() => {
|
||||
db.update(items)
|
||||
.set({ categoryId: 1 })
|
||||
.where(eq(items.categoryId, id))
|
||||
.run();
|
||||
|
||||
db.delete(categories).where(eq(categories.id, id)).run();
|
||||
await tx.delete(categories).where(eq(categories.id, id));
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import type { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
import { getOrCreateUncategorized } from "./category.service.ts";
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import { globalItems, itemGlobalLinks } from "../../db/schema.ts";
|
||||
type Db = typeof prodDb;
|
||||
|
||||
/**
|
||||
* Search global items by brand or model. SQLite LIKE is case-insensitive for ASCII.
|
||||
* Search global items by brand or model. LIKE is case-insensitive for ASCII.
|
||||
* Escapes % and _ wildcard characters in user input.
|
||||
*/
|
||||
export function searchGlobalItems(db: Db = prodDb, query?: string) {
|
||||
export async function searchGlobalItems(db: Db = prodDb, query?: string) {
|
||||
if (!query) {
|
||||
return db.select().from(globalItems).all();
|
||||
return db.select().from(globalItems);
|
||||
}
|
||||
|
||||
// Escape SQL LIKE wildcards
|
||||
@@ -21,31 +21,28 @@ export function searchGlobalItems(db: Db = prodDb, query?: string) {
|
||||
.select()
|
||||
.from(globalItems)
|
||||
.where(
|
||||
or(
|
||||
like(globalItems.brand, pattern),
|
||||
like(globalItems.model, pattern),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
or(like(globalItems.brand, pattern), like(globalItems.model, pattern)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single global item by ID with the count of user items linked to it.
|
||||
*/
|
||||
export function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
|
||||
const item = db
|
||||
export async function getGlobalItemWithOwnerCount(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
) {
|
||||
const [item] = await db
|
||||
.select()
|
||||
.from(globalItems)
|
||||
.where(eq(globalItems.id, id))
|
||||
.get();
|
||||
.where(eq(globalItems.id, id));
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const result = db
|
||||
const [result] = await db
|
||||
.select({ ownerCount: count() })
|
||||
.from(itemGlobalLinks)
|
||||
.where(eq(itemGlobalLinks.globalItemId, id))
|
||||
.get();
|
||||
.where(eq(itemGlobalLinks.globalItemId, id));
|
||||
|
||||
return { ...item, ownerCount: result?.ownerCount ?? 0 };
|
||||
}
|
||||
@@ -53,27 +50,26 @@ export function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
|
||||
/**
|
||||
* Link a user's item to a global item. Throws on duplicate (unique constraint on itemId).
|
||||
*/
|
||||
export function linkItemToGlobal(
|
||||
export async function linkItemToGlobal(
|
||||
db: Db = prodDb,
|
||||
itemId: number,
|
||||
globalItemId: number,
|
||||
) {
|
||||
return db
|
||||
const [row] = await db
|
||||
.insert(itemGlobalLinks)
|
||||
.values({ itemId, globalItemId })
|
||||
.returning()
|
||||
.get();
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the link between a user's item and any global item.
|
||||
*/
|
||||
export function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) {
|
||||
const result = db
|
||||
export async function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) {
|
||||
const result = await db
|
||||
.delete(itemGlobalLinks)
|
||||
.where(eq(itemGlobalLinks.itemId, itemId))
|
||||
.returning()
|
||||
.all();
|
||||
.returning();
|
||||
|
||||
return result.length;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import type { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
import type { CreateItem } from "../../shared/types.ts";
|
||||
|
||||
@@ -146,9 +146,7 @@ export async function deleteItem(db: Db, userId: number, id: number) {
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
await db
|
||||
.delete(items)
|
||||
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||
await db.delete(items).where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -86,10 +86,7 @@ export async function exchangeCode(
|
||||
if (computedChallenge !== record.codeChallenge) return null;
|
||||
|
||||
// Mark code as used
|
||||
await db
|
||||
.update(oauthCodes)
|
||||
.set({ used: 1 })
|
||||
.where(eq(oauthCodes.code, code));
|
||||
await db.update(oauthCodes).set({ used: 1 }).where(eq(oauthCodes.code, code));
|
||||
|
||||
return generateTokens(db, clientId, userId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import type { db as prodDb } from "../../db/index.ts";
|
||||
import {
|
||||
categories,
|
||||
items,
|
||||
@@ -16,10 +16,7 @@ export async function updateProfile(
|
||||
userId: number,
|
||||
data: UpdateProfile,
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
const [existing] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!existing) return null;
|
||||
|
||||
// If no fields to update, return existing user
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import type { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items, setupItems, setups } from "../../db/schema.ts";
|
||||
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export async function createSetup(
|
||||
db: Db,
|
||||
userId: number,
|
||||
data: CreateSetup,
|
||||
) {
|
||||
export async function createSetup(db: Db, userId: number, data: CreateSetup) {
|
||||
const [row] = await db
|
||||
.insert(setups)
|
||||
.values({ name: data.name, userId, isPublic: data.isPublic ?? false })
|
||||
@@ -110,11 +106,7 @@ export async function updateSetup(
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteSetup(
|
||||
db: Db,
|
||||
userId: number,
|
||||
setupId: number,
|
||||
) {
|
||||
export async function deleteSetup(db: Db, userId: number, setupId: number) {
|
||||
const [existing] = await db
|
||||
.select({ id: setups.id })
|
||||
.from(setups)
|
||||
@@ -197,9 +189,7 @@ export async function updateItemClassification(
|
||||
await db
|
||||
.update(setupItems)
|
||||
.set({ classification })
|
||||
.where(
|
||||
and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)),
|
||||
);
|
||||
.where(and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)));
|
||||
}
|
||||
|
||||
export async function removeSetupItem(
|
||||
@@ -217,7 +207,5 @@ export async function removeSetupItem(
|
||||
|
||||
await db
|
||||
.delete(setupItems)
|
||||
.where(
|
||||
and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)),
|
||||
);
|
||||
.where(and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)));
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ export async function getImageUrl(filename: string): Promise<string> {
|
||||
* Enrich a record that has an imageFilename with a presigned imageUrl.
|
||||
* Returns null imageUrl when imageFilename is null.
|
||||
*/
|
||||
export async function withImageUrl<
|
||||
T extends { imageFilename: string | null },
|
||||
>(record: T): Promise<T & { imageUrl: string | null }> {
|
||||
export async function withImageUrl<T extends { imageFilename: string | null }>(
|
||||
record: T,
|
||||
): Promise<T & { imageUrl: string | null }> {
|
||||
return {
|
||||
...record,
|
||||
imageUrl: record.imageFilename
|
||||
@@ -76,8 +76,8 @@ export async function withImageUrl<
|
||||
/**
|
||||
* Batch version of withImageUrl. Uses Promise.all for parallelism.
|
||||
*/
|
||||
export async function withImageUrls<
|
||||
T extends { imageFilename: string | null },
|
||||
>(records: T[]): Promise<(T & { imageUrl: string | null })[]> {
|
||||
export async function withImageUrls<T extends { imageFilename: string | null }>(
|
||||
records: T[],
|
||||
): Promise<(T & { imageUrl: string | null })[]> {
|
||||
return Promise.all(records.map((record) => withImageUrl(record)));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, asc, desc, eq, max, sql } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import type { db as prodDb } from "../../db/index.ts";
|
||||
import {
|
||||
categories,
|
||||
items,
|
||||
@@ -15,11 +15,7 @@ import { getOrCreateUncategorized } from "./category.service.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export async function createThread(
|
||||
db: Db,
|
||||
userId: number,
|
||||
data: CreateThread,
|
||||
) {
|
||||
export async function createThread(db: Db, userId: number, data: CreateThread) {
|
||||
const [row] = await db
|
||||
.insert(threads)
|
||||
.values({ name: data.name, categoryId: data.categoryId, userId })
|
||||
@@ -258,9 +254,7 @@ export async function deleteCandidate(
|
||||
const [thread] = await db
|
||||
.select({ id: threads.id })
|
||||
.from(threads)
|
||||
.where(
|
||||
and(eq(threads.id, candidate.threadId), eq(threads.userId, userId)),
|
||||
);
|
||||
.where(and(eq(threads.id, candidate.threadId), eq(threads.userId, userId)));
|
||||
if (!thread) return null;
|
||||
|
||||
await db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import type { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
Reference in New Issue
Block a user