chore(18-03): apply 18-01 schema foundation as dependency baseline

This commit is contained in:
2026-04-05 13:04:09 +02:00
parent f7c9f3dc94
commit 89b0496845
20 changed files with 3022 additions and 473 deletions

View File

@@ -1,147 +1,65 @@
import { randomBytes } from "node:crypto";
import { count, eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { apiKeys, sessions, users } from "../../db/schema.ts";
import { apiKeys, users } from "../../db/schema.ts";
type Db = typeof prodDb;
// ── User Management ──────────────────────────────────────────────────
export async function createUser(
db: Db = prodDb,
username: string,
password: string,
) {
const passwordHash = await Bun.password.hash(password);
return db.insert(users).values({ username, passwordHash }).returning().get();
}
export async function verifyPassword(
db: Db = prodDb,
username: string,
password: string,
) {
const user = db
.select()
.from(users)
.where(eq(users.username, username))
.get();
if (!user) return null;
const valid = await Bun.password.verify(password, user.passwordHash);
return valid ? user : null;
}
export function getUserCount(db: Db = prodDb): number {
const result = db.select({ value: count() }).from(users).get();
return result?.value ?? 0;
}
export async function changePassword(
db: Db = prodDb,
username: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const user = await verifyPassword(db, username, currentPassword);
if (!user) return false;
const newHash = await Bun.password.hash(newPassword);
db.update(users)
.set({ passwordHash: newHash })
.where(eq(users.id, user.id))
.run();
return true;
}
// ── Session Management ───────────────────────────────────────────────
export function createSession(
db: Db = prodDb,
userId: number,
expiryDays = 30,
) {
const id = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
return db
.insert(sessions)
.values({ id, userId, expiresAt })
.returning()
.get();
}
export function getSession(db: Db = prodDb, sessionId: string) {
const session = db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.get();
if (!session) return null;
if (session.expiresAt < new Date()) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
return null;
}
return session;
}
export function deleteSession(db: Db = prodDb, sessionId: string) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
}
export function refreshSession(
db: Db = prodDb,
sessionId: string,
expiryDays = 30,
) {
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
db.update(sessions)
.set({ expiresAt })
.where(eq(sessions.id, sessionId))
.run();
export async function getOrCreateUser(
db: Db,
logtoSub: string,
): Promise<{ id: number }> {
const [user] = await db
.insert(users)
.values({ logtoSub })
.onConflictDoUpdate({
target: users.logtoSub,
set: { logtoSub },
})
.returning({ id: users.id });
return user;
}
// ── API Key Management ───────────────────────────────────────────────
export async function createApiKey(db: Db = prodDb, 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);
const record = db
const [record] = await db
.insert(apiKeys)
.values({ name, keyHash, keyPrefix })
.returning()
.get();
.values({ name, keyHash, keyPrefix, userId })
.returning();
return { ...record, rawKey };
}
export async function verifyApiKey(
db: Db = prodDb,
db: Db,
rawKey: string,
): Promise<boolean> {
): Promise<{ userId: number } | null> {
const prefix = rawKey.slice(0, 8);
const candidates = db
const candidates = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.keyPrefix, prefix))
.all();
.where(eq(apiKeys.keyPrefix, prefix));
for (const candidate of candidates) {
const valid = await Bun.password.verify(rawKey, candidate.keyHash);
if (valid) return true;
if (valid) return { userId: candidate.userId };
}
return false;
return null;
}
export function listApiKeys(db: Db = prodDb) {
export async function listApiKeys(db: Db, userId: number) {
return db
.select({
id: apiKeys.id,
@@ -150,9 +68,11 @@ export function listApiKeys(db: Db = prodDb) {
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.all();
.where(eq(apiKeys.userId, userId));
}
export function deleteApiKey(db: Db = prodDb, id: number) {
db.delete(apiKeys).where(eq(apiKeys.id, id)).run();
export async function deleteApiKey(db: Db, userId: number, id: number) {
await db
.delete(apiKeys)
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
}

View File

@@ -1,15 +1,24 @@
import { eq, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { 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 function createSetup(db: Db = prodDb, data: CreateSetup) {
return db.insert(setups).values({ name: data.name }).returning().get();
export async function createSetup(
db: Db,
userId: number,
data: CreateSetup,
) {
const [row] = await db
.insert(setups)
.values({ name: data.name, userId })
.returning();
return row;
}
export function getAllSetups(db: Db = prodDb) {
export async function getAllSetups(db: Db, userId: number) {
return db
.select({
id: setups.id,
@@ -32,14 +41,21 @@ export function getAllSetups(db: Db = prodDb) {
), 0)`.as("total_cost"),
})
.from(setups)
.all();
.where(eq(setups.userId, userId));
}
export function getSetupWithItems(db: Db = prodDb, setupId: number) {
const setup = db.select().from(setups).where(eq(setups.id, setupId)).get();
export async function getSetupWithItems(
db: Db,
userId: number,
setupId: number,
) {
const [setup] = await db
.select()
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
const itemList = db
const itemList = await db
.select({
id: items.id,
name: items.name,
@@ -59,59 +75,82 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) {
.from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.where(eq(setupItems.setupId, setupId))
.all();
.where(eq(setupItems.setupId, setupId));
return { ...setup, items: itemList };
}
export function updateSetup(
db: Db = prodDb,
export async function updateSetup(
db: Db,
userId: number,
setupId: number,
data: UpdateSetup,
) {
const existing = db
const [existing] = await db
.select({ id: setups.id })
.from(setups)
.where(eq(setups.id, setupId))
.get();
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!existing) return null;
return db
const [row] = await db
.update(setups)
.set({ name: data.name, updatedAt: new Date() })
.where(eq(setups.id, setupId))
.returning()
.get();
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)))
.returning();
return row;
}
export function deleteSetup(db: Db = prodDb, setupId: number) {
const existing = db
export async function deleteSetup(
db: Db,
userId: number,
setupId: number,
) {
const [existing] = await db
.select({ id: setups.id })
.from(setups)
.where(eq(setups.id, setupId))
.get();
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!existing) return false;
db.delete(setups).where(eq(setups.id, setupId)).run();
await db
.delete(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
return true;
}
export function syncSetupItems(
db: Db = prodDb,
export async function syncSetupItems(
db: Db,
userId: number,
setupId: number,
itemIds: number[],
) {
return db.transaction((tx) => {
return await db.transaction(async (tx) => {
// Verify the setup belongs to this user
const [setup] = await tx
.select({ id: setups.id })
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
// Verify all itemIds belong to this user
const validItems =
itemIds.length > 0
? await tx
.select({ id: items.id })
.from(items)
.where(and(eq(items.userId, userId), inArray(items.id, itemIds)))
: [];
const validItemIds = new Set(validItems.map((i) => i.id));
const filteredItemIds = itemIds.filter((id) => validItemIds.has(id));
// Save existing classifications before deleting
const existing = tx
const existing = await tx
.select({
itemId: setupItems.itemId,
classification: setupItems.classification,
})
.from(setupItems)
.where(eq(setupItems.setupId, setupId))
.all();
.where(eq(setupItems.setupId, setupId));
const classificationMap = new Map<number, string>();
for (const row of existing) {
@@ -119,43 +158,57 @@ export function syncSetupItems(
}
// Delete all existing items for this setup
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
await tx.delete(setupItems).where(eq(setupItems.setupId, setupId));
// Re-insert new items, preserving classifications for retained items
for (const itemId of itemIds) {
tx.insert(setupItems)
.values({
setupId,
itemId,
classification: classificationMap.get(itemId) ?? "base",
})
.run();
// Re-insert only user-owned items, preserving classifications
for (const itemId of filteredItemIds) {
await tx.insert(setupItems).values({
setupId,
itemId,
classification: classificationMap.get(itemId) ?? "base",
});
}
});
}
export function updateItemClassification(
db: Db = prodDb,
export async function updateItemClassification(
db: Db,
userId: number,
setupId: number,
itemId: number,
classification: string,
) {
db.update(setupItems)
// Verify setup belongs to user
const [setup] = await db
.select({ id: setups.id })
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
await db
.update(setupItems)
.set({ classification })
.where(
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
)
.run();
and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)),
);
}
export function removeSetupItem(
db: Db = prodDb,
export async function removeSetupItem(
db: Db,
userId: number,
setupId: number,
itemId: number,
) {
db.delete(setupItems)
// Verify setup belongs to user
const [setup] = await db
.select({ id: setups.id })
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
await db
.delete(setupItems)
.where(
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
)
.run();
and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)),
);
}

View File

@@ -0,0 +1,83 @@
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
// MinIO GitHub repository was archived Feb 2026. The S3 API abstraction
// makes the underlying provider swappable (SeaweedFS, Garage, AWS S3, etc.)
// without code changes.
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION ?? "us-east-1",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
forcePathStyle: true, // REQUIRED for MinIO and most S3-compatible services
});
const bucket = process.env.S3_BUCKET ?? "gearbox-images";
const presignExpiry = Number.parseInt(
process.env.S3_PRESIGN_EXPIRY ?? "3600",
10,
);
export async function uploadImage(
buffer: Buffer | ArrayBuffer,
filename: string,
contentType: string,
): Promise<void> {
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: filename,
Body: Buffer.from(buffer),
ContentType: contentType,
}),
);
}
export async function deleteImage(filename: string): Promise<void> {
await s3.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: filename,
}),
);
}
export async function getImageUrl(filename: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: filename,
});
return getSignedUrl(s3, command, { expiresIn: presignExpiry });
}
/**
* 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 }> {
return {
...record,
imageUrl: record.imageFilename
? await getImageUrl(record.imageFilename)
: null,
};
}
/**
* 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 })[]> {
return Promise.all(records.map((record) => withImageUrl(record)));
}