chore(18-03): apply 18-01 schema foundation as dependency baseline
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
83
src/server/services/storage.service.ts
Normal file
83
src/server/services/storage.service.ts
Normal 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user