chore: remediate pygienium audit findings

Dead code: 77 verified-unused exports, files (BackArrow, MenuBars,
cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and
12 unused dependencies removed
Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose
blocks tightened; dead commented-out config removed
Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10);
login formHandler split into 3 submitters (CCN 63->~5); account page render
split into 8 section components (CCN 40); updatePost SQL builder rebuilt;
assert*Owned consolidated behind generic assertOwnedBy
Defensive guards: 4 redundant rethrow/nullish guards removed
This commit is contained in:
2026-08-11 13:36:18 -04:00
parent 33ca9213f2
commit 898c891bd5
76 changed files with 1437 additions and 2600 deletions

View File

@@ -80,11 +80,9 @@ import {
* In development: ctx.event might be H3Event directly
*/
function getH3Event(ctx: Context): H3Event {
// Check if nativeEvent exists (production)
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
return ctx.event.nativeEvent as H3Event;
}
// Otherwise, assume ctx.event is H3Event (development)
return ctx.event as unknown as H3Event;
}
@@ -245,7 +243,6 @@ export const authRouter = createTRPCRouter({
try {
await conn.execute({ sql: insertQuery, args: insertParams });
// Also create UserProvider entry for new user
await linkProvider(userId, "github", {
providerUserId: login,
email: email,
@@ -300,7 +297,6 @@ export const authRouter = createTRPCRouter({
} catch (error) {
console.error("[GitHub Callback] Error during OAuth flow:", error);
// Log failed OAuth login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.login.failed",
@@ -452,7 +448,6 @@ export const authRouter = createTRPCRouter({
args: insertParams
});
// Also create UserProvider entry for new user
await linkProvider(userId, "google", {
providerUserId: email,
email: email,
@@ -481,7 +476,6 @@ export const authRouter = createTRPCRouter({
}
}
// Issue JWT (OAuth defaults to remember me)
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
@@ -631,7 +625,6 @@ export const authRouter = createTRPCRouter({
} catch (error) {
console.error("[Email Login] Error during login:", error);
// Log failed email link login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.login.failed",
@@ -685,10 +678,7 @@ export const authRouter = createTRPCRouter({
});
}
// Check if there's a valid JWT token with this code
// We need to find the token that was generated for this email
// Since we can't store tokens in DB efficiently, we'll verify against the cookie
// Get the token from cookie (we'll store it when sending email)
// Tokens aren't stored in DB; verify the code against the JWT cookie set when the email was sent
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
if (!storedToken) {
throw new TRPCError({
@@ -697,7 +687,6 @@ export const authRouter = createTRPCRouter({
});
}
// Verify the JWT and check the code
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
let payload;
try {
@@ -756,7 +745,6 @@ export const authRouter = createTRPCRouter({
} catch (error) {
console.error("[Email Code Login] Error during login:", error);
// Log failed code login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.login.failed",
@@ -808,7 +796,6 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory();
// Get user ID for audit log
const userRes = await conn.execute({
sql: "SELECT id FROM User WHERE email = ?",
args: [email]
@@ -819,7 +806,6 @@ export const authRouter = createTRPCRouter({
const params = [true, email];
await conn.execute({ sql: query, args: params });
// Log successful email verification
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId,
@@ -835,7 +821,6 @@ export const authRouter = createTRPCRouter({
message: "Email verification success, you may close this window"
};
} catch (error) {
// Log failed email verification
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.email.verify.complete",
@@ -864,7 +849,6 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation, rememberMe } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
await rateLimitRegistration(clientIP, getH3Event(ctx));
@@ -876,10 +860,8 @@ export const authRouter = createTRPCRouter({
});
}
// Check if email already exists (User table or UserProvider table)
const existingUserId = await findUserByEmail(email);
if (existingUserId) {
// User exists - check if they have a password
const conn = ConnectionFactory();
const userCheck = await conn.execute({
sql: "SELECT password_hash, provider FROM User WHERE id = ?",
@@ -916,13 +898,11 @@ export const authRouter = createTRPCRouter({
args: [userId, email, passwordHash, "email"]
});
// Create UserProvider entry for email auth
await linkProvider(userId, "email", {
providerUserId: email,
email: email
});
// Issue auth token with client info
const event = getH3Event(ctx);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
@@ -930,13 +910,11 @@ export const authRouter = createTRPCRouter({
await issueAuthToken({
event,
userId,
rememberMe: rememberMe ?? true
rememberMe,
});
// Set CSRF token
setCSRFToken(event);
// Log successful registration
await logAuditEvent({
userId,
eventType: "auth.register.success",
@@ -948,7 +926,6 @@ export const authRouter = createTRPCRouter({
return { success: true, message: "success" };
} catch (e) {
// Log failed registration
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.register.failed",
@@ -976,7 +953,6 @@ export const authRouter = createTRPCRouter({
try {
const { email, password, rememberMe } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
await rateLimitLogin(email, clientIP, getH3Event(ctx));
@@ -992,9 +968,7 @@ export const authRouter = createTRPCRouter({
const passwordHash = user?.password_hash || null;
const passwordMatch = await checkPasswordSafe(password, passwordHash);
// Check all conditions after password verification
if (!user || !passwordHash || !passwordMatch) {
// Record failed login attempt if user exists
if (user?.id) {
const lockoutStatus = await recordFailedLogin(user.id);
@@ -1032,7 +1006,6 @@ export const authRouter = createTRPCRouter({
}
}
// Log failed login attempt
try {
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
@@ -1060,7 +1033,6 @@ export const authRouter = createTRPCRouter({
});
}
// Check if account is locked before allowing login
const lockoutCheck = await checkAccountLockout(user.id);
if (lockoutCheck.isLocked) {
const remainingSec = Math.ceil(
@@ -1083,22 +1055,18 @@ export const authRouter = createTRPCRouter({
});
}
// Reset failed attempts on successful login
await resetFailedAttempts(user.id);
// Reset rate limits on successful login
await resetLoginRateLimits(email, clientIP);
// Issue JWT for authenticated user
const event = getH3Event(ctx);
const userAgent = getUserAgent(event);
await issueAuthToken({
event,
userId: user.id,
rememberMe: rememberMe ?? false
rememberMe,
});
// Set CSRF token for authenticated user
setCSRFToken(event);
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
@@ -1106,7 +1074,7 @@ export const authRouter = createTRPCRouter({
await logAuditEvent({
userId: user.id,
eventType: "auth.login.success",
eventData: { method: "password", rememberMe: rememberMe ?? false },
eventData: { method: "password", rememberMe },
ipAddress: clientIP,
userAgent,
success: true
@@ -1251,7 +1219,6 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
await rateLimitPasswordReset(clientIP, getH3Event(ctx));
@@ -1303,7 +1270,6 @@ export const authRouter = createTRPCRouter({
}
);
// Log password reset request
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId: user.id,
@@ -1316,7 +1282,6 @@ export const authRouter = createTRPCRouter({
return { success: true, message: "email sent" };
} catch (error) {
// Log failed password reset request (only if not rate limited)
if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
) {
@@ -1371,7 +1336,6 @@ export const authRouter = createTRPCRouter({
}
try {
// Validate and consume the password reset token
const tokenValidation = await validatePasswordResetToken(token);
if (!tokenValidation) {
@@ -1415,10 +1379,8 @@ export const authRouter = createTRPCRouter({
});
}
// Mark token as used
await markPasswordResetTokenUsed(tokenId);
// Log successful password reset
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId: userId,
@@ -1431,7 +1393,6 @@ export const authRouter = createTRPCRouter({
return { success: true, message: "success" };
} catch (error) {
// Log failed password reset
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
eventType: "auth.password.reset.complete",
@@ -1459,7 +1420,6 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx));
await rateLimitEmailVerification(clientIP, getH3Event(ctx));
@@ -1520,7 +1480,6 @@ export const authRouter = createTRPCRouter({
}
);
// Log email verification request
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({
userId: user.id,
@@ -1533,7 +1492,6 @@ export const authRouter = createTRPCRouter({
return { success: true, message: "Verification email sent" };
} catch (error) {
// Log failed email verification request (only if not rate limited)
if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
) {

View File

@@ -462,58 +462,50 @@ export const databaseRouter = createTRPCRouter({
}
}
let query = "UPDATE Post SET ";
let sets: string[] = [];
let params: any[] = [];
let first = true;
if (input.title !== undefined && input.title !== null) {
query += first ? "title = ?" : ", title = ?";
sets.push("title = ?");
params.push(input.title);
first = false;
}
if (input.subtitle !== undefined && input.subtitle !== null) {
query += first ? "subtitle = ?" : ", subtitle = ?";
sets.push("subtitle = ?");
params.push(input.subtitle);
first = false;
}
if (input.body !== undefined && input.body !== null) {
query += first ? "body = ?" : ", body = ?";
sets.push("body = ?");
params.push(input.body);
first = false;
}
if (input.banner_photo !== undefined && input.banner_photo !== null) {
query += first ? "banner_photo = ?" : ", banner_photo = ?";
sets.push("banner_photo = ?");
if (input.banner_photo === "_DELETE_IMAGE_") {
params.push(null);
} else {
params.push(env.VITE_AWS_BUCKET_STRING + input.banner_photo);
}
first = false;
}
if (input.published !== undefined && input.published !== null) {
query += first ? "published = ?" : ", published = ?";
sets.push("published = ?");
params.push(input.published);
first = false;
}
if (shouldSetPublishDate) {
query += first ? "date = ?" : ", date = ?";
sets.push("date = ?");
params.push(new Date().toISOString());
first = false;
}
query += first ? "last_edited_date = ?" : ", last_edited_date = ?";
sets.push("last_edited_date = ?");
params.push(new Date().toISOString());
first = false;
query += first ? "author_id = ?" : ", author_id = ?";
sets.push("author_id = ?");
params.push(input.author_id);
query += " WHERE id = ?";
let query = "UPDATE Post SET " + sets.join(", ") + " WHERE id = ?";
params.push(input.id);
const results = await conn.execute({ sql: query, args: params });

View File

@@ -36,7 +36,6 @@ async function getLatestDMG(
throw new Error(`No DMG files found in S3 with prefix ${prefix}`);
}
// Filter for .dmg files only and sort by LastModified (newest first)
const dmgFiles = response.Contents.filter((obj) =>
obj.Key?.endsWith(".dmg")
).sort((a, b) => {
@@ -103,7 +102,6 @@ export const downloadsRouter = createTRPCRouter({
} else if (input.asset_name === "inputhalo") {
fileKey = await getLatestInputHaloDMG(client, bucket);
} else {
// Use static mapping for other assets
fileKey = assets[input.asset_name];
if (!fileKey) {

View File

@@ -33,7 +33,6 @@ export const gitActivityRouter = createTRPCRouter({
`github-commits-${input.limit}`,
CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
async () => {
// Use Events API to get recent push events
const eventsResponse = await fetchWithTimeout(
`https://api.github.com/users/MikeFreno/events/public?per_page=100`,
{
@@ -48,7 +47,6 @@ export const gitActivityRouter = createTRPCRouter({
await checkResponse(eventsResponse);
const events = await eventsResponse.json();
// Collect (repo, sha) pairs from push events up front
const toFetch: { repoName: string; sha: string }[] = [];
for (const event of events) {
if (event.type !== "PushEvent") continue;

View File

@@ -72,13 +72,7 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
// Account-deletion request email — product-aware
// ============================================================
//
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
// tested in `bun:test` without a populated `.env`. Re-exported here for the
// tRPC mutation below + for callers that already import from `misc`.
// Import into local scope FIRST — `sendDeletionRequestEmail` below uses
// these names directly. A bare `export { ... } from` re-export does NOT make
// the bindings available locally, which caused a ReferenceError that crashed
// the entire tRPC router (503 on every /api/trpc call).
// Bare "export … from" doesn't bind names locally — import first or the router throws ReferenceError (503s every /api/trpc call)
import {
DELETION_PRODUCT_SCHEMA,
deletionCookieName,
@@ -256,7 +250,6 @@ export const miscRouter = createTRPCRouter({
lastModified: item.LastModified?.toISOString() || ""
})) || [];
// Filter out thumbnail files (ending with -small.ext)
const mainFiles = files.filter(
(file) => !file.key.match(/-small\.(jpg|jpeg|png|gif)$/i)
);
@@ -376,7 +369,6 @@ export const miscRouter = createTRPCRouter({
})
)
.mutation(async ({ input }) => {
// Verify Cloudflare Turnstile token
const turnstileValid = await verifyTurnstileToken(
input.turnstileToken,
env.TURNSTILE_SECRET_KEY,

View File

@@ -10,6 +10,7 @@
*/
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { TRPCError } from "@trpc/server";
import type { Client } from "@libsql/client/web";
// Prevent the env/server.ts client-side guard from throwing during tests
@@ -288,7 +289,6 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
for (const name of MUTATIONS) {
// Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx
const re = new RegExp(
`${name}:\\s*nessaProcedure[^}]*\\.mutation\\(async \\({\\s*input\\s*}\\)`,
"s"
@@ -305,7 +305,6 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
for (const name of MUTATIONS) {
// Find the block for this mutation and check it references ctx
const re = new RegExp(
`${name}:\\s*nessaProcedure[\\s\\S]*?\\.mutation\\([\\s\\S]*?\\n \\}\\),`,
"s"
@@ -318,12 +317,55 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
}
});
it("bulkUpsert filters exerciseLibrary by userId", async () => {
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
const bulkSection = source.match(
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n {8}\}/
);
expect(bulkSection).toBeTruthy();
expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId");
it("upsertExerciseLibrary rejects an exercise owned by another user", async () => {
const mod = await import("./nessa");
const conn = makeMockConn([]);
const exercise = {
id: EXERCISE_ID,
userId: USER_B,
name: "Squat",
category: "Strength"
};
let caught: unknown;
try {
await mod.upsertExerciseLibrary(conn, USER_A, [exercise]);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(TRPCError);
expect((caught as TRPCError).code).toBe("FORBIDDEN");
expect((caught as TRPCError).message).toBe("User mismatch");
});
it("upsertExerciseLibrary upserts an exercise owned by the caller", async () => {
const mod = await import("./nessa");
const conn = makeMockConn([{ userId: USER_A }]);
const exercise = {
id: EXERCISE_ID,
userId: USER_A,
name: "Squat",
category: "Strength"
};
await expect(
mod.upsertExerciseLibrary(conn, USER_A, [exercise])
).resolves.toBeUndefined();
expect(conn.execute).toHaveBeenCalledWith({
sql: expect.stringContaining(
"INSERT INTO exerciseLibrary (id, userId, name, category"
),
args: [
EXERCISE_ID,
USER_A,
"Squat",
"Strength",
null,
null,
null,
null,
null,
null,
null
]
});
});
});

View File

@@ -7,25 +7,45 @@ import type { Client } from "@libsql/client/web";
const NESSA_CACHE_TTL_MS = 5 * 60 * 1000;
/**
* Assert that the record identified by id in the given table belongs to userId.
* Shared by assertWorkoutOwned, assertAuthProviderOwned, and
* assertExerciseLibraryOwned.
*/
async function assertOwnedBy(
conn: Client,
table: string,
id: string,
userId: string,
notFoundMessage: string,
forbiddenMessage: string
) {
const row = await conn.execute({
sql: `SELECT userId FROM ${table} WHERE id = ?`,
args: [id]
});
if (row.rows.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: notFoundMessage });
}
if (row.rows[0].userId !== userId) {
throw new TRPCError({ code: "FORBIDDEN", message: forbiddenMessage });
}
}
/** Assert that the workout identified by workoutId is owned by userId */
export async function assertWorkoutOwned(
conn: Client,
workoutId: string,
userId: string
) {
const row = await conn.execute({
sql: "SELECT userId FROM workouts WHERE id = ?",
args: [workoutId]
});
if (row.rows.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Workout not found" });
}
if ((row.rows[0] as any).userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not the workout owner"
});
}
await assertOwnedBy(
conn,
"workouts",
workoutId,
userId,
"Workout not found",
"Not the workout owner"
);
}
/** Assert that the auth provider record identified by providerId is owned by userId */
@@ -34,22 +54,14 @@ export async function assertAuthProviderOwned(
providerId: string,
userId: string
) {
const row = await conn.execute({
sql: "SELECT userId FROM authProviders WHERE id = ?",
args: [providerId]
});
if (row.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Auth provider not found"
});
}
if ((row.rows[0] as any).userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not the auth provider owner"
});
}
await assertOwnedBy(
conn,
"authProviders",
providerId,
userId,
"Auth provider not found",
"Not the auth provider owner"
);
}
/** Assert that the exercise library record identified by exerciseId is owned by userId */
@@ -58,19 +70,14 @@ export async function assertExerciseLibraryOwned(
exerciseId: string,
userId: string
) {
const row = await conn.execute({
sql: "SELECT userId FROM exerciseLibrary WHERE id = ?",
args: [exerciseId]
});
if (row.rows.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Exercise not found" });
}
if ((row.rows[0] as any).userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not the exercise owner"
});
}
await assertOwnedBy(
conn,
"exerciseLibrary",
exerciseId,
userId,
"Exercise not found",
"Not the exercise owner"
);
}
const paginatedQuerySchema = z.object({
@@ -239,6 +246,401 @@ const bulkSchema = z.object({
authProviders: z.array(providerSchema).optional()
});
async function upsertUsers(
conn: Client,
userId: string,
users: z.infer<typeof userInputSchema>[]
) {
for (const user of users) {
if (user.id !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET email = excluded.email, emailVerified = excluded.emailVerified, firstName = excluded.firstName, lastName = excluded.lastName, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, provider = excluded.provider, appleUserId = excluded.appleUserId, status = excluded.status, updatedAt = datetime('now')`,
args: [
user.id,
user.email ?? null,
user.emailVerified ?? 0,
user.firstName ?? null,
user.lastName ?? null,
user.displayName ?? null,
user.avatarUrl ?? null,
user.provider ?? null,
user.appleUserId ?? null,
user.status ?? "active"
]
});
}
}
export async function upsertExerciseLibrary(
conn: Client,
userId: string,
exerciseLibrary: z.infer<typeof exerciseLibrarySchema>[]
) {
for (const exercise of exerciseLibrary) {
if (exercise.userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET userId = excluded.userId, name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`,
args: [
exercise.id,
exercise.userId,
exercise.name,
exercise.category,
exercise.muscleGroups ?? null,
exercise.equipment ?? null,
exercise.instructions ?? null,
exercise.defaultSets ?? null,
exercise.defaultReps ?? null,
exercise.defaultRestSeconds ?? null,
exercise.notes ?? null
]
});
}
}
async function upsertWorkoutPlans(
conn: Client,
userId: string,
workoutPlans: z.infer<typeof workoutPlanSchema>[]
) {
for (const plan of workoutPlans) {
if (plan.userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO workoutPlans (id, userId, name, description, category, difficulty, durationMinutes, type, isPublic)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, category = excluded.category, difficulty = excluded.difficulty, durationMinutes = excluded.durationMinutes, type = excluded.type, isPublic = excluded.isPublic, updatedAt = datetime('now')`,
args: [
plan.id,
plan.userId,
plan.name,
plan.description ?? null,
plan.category,
plan.difficulty ?? "intermediate",
plan.durationMinutes ?? null,
plan.type,
plan.isPublic ?? 0
]
});
}
}
async function upsertPlanExercises(
conn: Client,
userId: string,
planExercises: z.infer<typeof planExerciseSchema>[]
) {
for (const planExercise of planExercises) {
const planCheck = await conn.execute({
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
args: [planExercise.planId]
});
if (
!planCheck.rows.length ||
planCheck.rows[0].userId !== userId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO planExercises (id, planId, exerciseId, name, category, orderIndex, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET exerciseId = excluded.exerciseId, name = excluded.name, category = excluded.category, orderIndex = excluded.orderIndex, notes = excluded.notes`,
args: [
planExercise.id,
planExercise.planId,
planExercise.exerciseId ?? null,
planExercise.name,
planExercise.category,
planExercise.orderIndex,
planExercise.notes ?? null
]
});
}
}
async function upsertPlanSets(
conn: Client,
userId: string,
planSets: z.infer<typeof planSetSchema>[]
) {
for (const planSet of planSets) {
const planExerciseCheck = await conn.execute({
sql: "SELECT planId FROM planExercises WHERE id = ?",
args: [planSet.planExerciseId]
});
if (planExerciseCheck.rows.length) {
const planCheck = await conn.execute({
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
args: [planExerciseCheck.rows[0].planId]
});
if (
!planCheck.rows.length ||
planCheck.rows[0].userId !== userId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
}
await conn.execute({
sql: `INSERT INTO planSets (id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET setNumber = excluded.setNumber, reps = excluded.reps, weight = excluded.weight, durationSeconds = excluded.durationSeconds, rpe = excluded.rpe, restAfterSeconds = excluded.restAfterSeconds, isWarmup = excluded.isWarmup, isDropset = excluded.isDropset, notes = excluded.notes`,
args: [
planSet.id,
planSet.planExerciseId,
planSet.setNumber,
planSet.reps ?? null,
planSet.weight ?? null,
planSet.durationSeconds ?? null,
planSet.rpe ?? null,
planSet.restAfterSeconds ?? null,
planSet.isWarmup ?? 0,
planSet.isDropset ?? 0,
planSet.notes ?? null
]
});
}
}
async function upsertRoutePoints(
conn: Client,
userId: string,
routePoints: z.infer<typeof routePointSchema>[]
) {
for (const point of routePoints) {
const planCheck = await conn.execute({
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
args: [point.planId]
});
if (
!planCheck.rows.length ||
planCheck.rows[0].userId !== userId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO routePoints (id, planId, latitude, longitude, orderIndex, isWaypoint)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET latitude = excluded.latitude, longitude = excluded.longitude, orderIndex = excluded.orderIndex, isWaypoint = excluded.isWaypoint`,
args: [
point.id,
point.planId,
point.latitude,
point.longitude,
point.orderIndex,
point.isWaypoint ?? 0
]
});
}
}
async function upsertWorkouts(
conn: Client,
userId: string,
workouts: z.infer<typeof workoutSchema>[]
) {
for (const workout of workouts) {
if (workout.userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO workouts (id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET planId = excluded.planId, type = excluded.type, name = excluded.name, startDate = excluded.startDate, endDate = excluded.endDate, durationSeconds = excluded.durationSeconds, distanceMeters = excluded.distanceMeters, calories = excluded.calories, averageHeartRate = excluded.averageHeartRate, maxHeartRate = excluded.maxHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, status = excluded.status, source = excluded.source, healthKitUUID = excluded.healthKitUUID, notes = excluded.notes, updatedAt = datetime('now')`,
args: [
workout.id,
workout.userId,
workout.planId ?? null,
workout.type,
workout.name ?? null,
workout.startDate,
workout.endDate ?? null,
workout.durationSeconds ?? null,
workout.distanceMeters ?? null,
workout.calories ?? null,
workout.averageHeartRate ?? null,
workout.maxHeartRate ?? null,
workout.averagePace ?? null,
workout.elevationGain ?? null,
workout.status,
workout.source,
workout.healthKitUUID ?? null,
workout.notes ?? null
]
});
}
}
async function upsertHeartRateSamples(
conn: Client,
userId: string,
heartRateSamples: z.infer<typeof heartRateSchema>[]
) {
for (const sample of heartRateSamples) {
const workoutCheck = await conn.execute({
sql: "SELECT userId FROM workouts WHERE id = ?",
args: [sample.workoutId]
});
if (
!workoutCheck.rows.length ||
workoutCheck.rows[0].userId !== userId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO heartRateSamples (id, workoutId, timestamp, bpm, source)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, bpm = excluded.bpm, source = excluded.source`,
args: [
sample.id,
sample.workoutId,
sample.timestamp,
sample.bpm,
sample.source ?? null
]
});
}
}
async function upsertLocationSamples(
conn: Client,
userId: string,
locationSamples: z.infer<typeof locationSampleSchema>[]
) {
for (const sample of locationSamples) {
const workoutCheck = await conn.execute({
sql: "SELECT userId FROM workouts WHERE id = ?",
args: [sample.workoutId]
});
if (
!workoutCheck.rows.length ||
workoutCheck.rows[0].userId !== userId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO locationSamples (id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, latitude = excluded.latitude, longitude = excluded.longitude, altitude = excluded.altitude, horizontalAccuracy = excluded.horizontalAccuracy, verticalAccuracy = excluded.verticalAccuracy, speed = excluded.speed, course = excluded.course`,
args: [
sample.id,
sample.workoutId,
sample.timestamp,
sample.latitude,
sample.longitude,
sample.altitude ?? null,
sample.horizontalAccuracy ?? null,
sample.verticalAccuracy ?? null,
sample.speed ?? null,
sample.course ?? null
]
});
}
}
async function upsertWorkoutSplits(
conn: Client,
userId: string,
workoutSplits: z.infer<typeof workoutSplitSchema>[]
) {
for (const split of workoutSplits) {
const workoutCheck = await conn.execute({
sql: "SELECT userId FROM workouts WHERE id = ?",
args: [split.workoutId]
});
if (
!workoutCheck.rows.length ||
workoutCheck.rows[0].userId !== userId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO workoutSplits (id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET splitNumber = excluded.splitNumber, distanceMeters = excluded.distanceMeters, durationSeconds = excluded.durationSeconds, startTimestamp = excluded.startTimestamp, endTimestamp = excluded.endTimestamp, averageHeartRate = excluded.averageHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, elevationLoss = excluded.elevationLoss`,
args: [
split.id,
split.workoutId,
split.splitNumber,
split.distanceMeters,
split.durationSeconds,
split.startTimestamp,
split.endTimestamp,
split.averageHeartRate ?? null,
split.averagePace ?? null,
split.elevationGain ?? null,
split.elevationLoss ?? null
]
});
}
}
async function upsertAuthProviders(
conn: Client,
userId: string,
authProviders: z.infer<typeof providerSchema>[]
) {
for (const provider of authProviders) {
if (provider.userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, providerUserId = excluded.providerUserId, email = excluded.email, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, lastUsedAt = datetime('now')`,
args: [
provider.id,
provider.userId,
provider.provider,
provider.providerUserId ?? null,
provider.email ?? null,
provider.displayName ?? null,
provider.avatarUrl ?? null
]
});
}
}
export const nessaDbRouter = createTRPCRouter({
health: nessaProcedure.query(async () => {
try {
@@ -1826,356 +2228,41 @@ export const nessaDbRouter = createTRPCRouter({
try {
const conn = NessaConnectionFactory();
if (input.users?.length) {
for (const user of input.users) {
if (user.id !== ctx.nessaUserId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET email = excluded.email, emailVerified = excluded.emailVerified, firstName = excluded.firstName, lastName = excluded.lastName, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, provider = excluded.provider, appleUserId = excluded.appleUserId, status = excluded.status, updatedAt = datetime('now')`,
args: [
user.id,
user.email ?? null,
user.emailVerified ?? 0,
user.firstName ?? null,
user.lastName ?? null,
user.displayName ?? null,
user.avatarUrl ?? null,
user.provider ?? null,
user.appleUserId ?? null,
user.status ?? "active"
]
});
}
}
if (input.exerciseLibrary?.length) {
for (const exercise of input.exerciseLibrary) {
if (exercise.userId !== ctx.nessaUserId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET userId = excluded.userId, name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`,
args: [
exercise.id,
exercise.userId,
exercise.name,
exercise.category,
exercise.muscleGroups ?? null,
exercise.equipment ?? null,
exercise.instructions ?? null,
exercise.defaultSets ?? null,
exercise.defaultReps ?? null,
exercise.defaultRestSeconds ?? null,
exercise.notes ?? null
]
});
}
}
if (input.workoutPlans?.length) {
for (const plan of input.workoutPlans) {
if (plan.userId !== ctx.nessaUserId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO workoutPlans (id, userId, name, description, category, difficulty, durationMinutes, type, isPublic)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, category = excluded.category, difficulty = excluded.difficulty, durationMinutes = excluded.durationMinutes, type = excluded.type, isPublic = excluded.isPublic, updatedAt = datetime('now')`,
args: [
plan.id,
plan.userId,
plan.name,
plan.description ?? null,
plan.category,
plan.difficulty ?? "intermediate",
plan.durationMinutes ?? null,
plan.type,
plan.isPublic ?? 0
]
});
}
}
if (input.planExercises?.length) {
for (const planExercise of input.planExercises) {
const planCheck = await conn.execute({
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
args: [planExercise.planId]
});
if (
!planCheck.rows.length ||
planCheck.rows[0].userId !== ctx.nessaUserId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO planExercises (id, planId, exerciseId, name, category, orderIndex, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET exerciseId = excluded.exerciseId, name = excluded.name, category = excluded.category, orderIndex = excluded.orderIndex, notes = excluded.notes`,
args: [
planExercise.id,
planExercise.planId,
planExercise.exerciseId ?? null,
planExercise.name,
planExercise.category,
planExercise.orderIndex,
planExercise.notes ?? null
]
});
}
}
if (input.planSets?.length) {
for (const planSet of input.planSets) {
const planExerciseCheck = await conn.execute({
sql: "SELECT planId FROM planExercises WHERE id = ?",
args: [planSet.planExerciseId]
});
if (planExerciseCheck.rows.length) {
const planCheck = await conn.execute({
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
args: [planExerciseCheck.rows[0].planId]
});
if (
!planCheck.rows.length ||
planCheck.rows[0].userId !== ctx.nessaUserId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
}
await conn.execute({
sql: `INSERT INTO planSets (id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET setNumber = excluded.setNumber, reps = excluded.reps, weight = excluded.weight, durationSeconds = excluded.durationSeconds, rpe = excluded.rpe, restAfterSeconds = excluded.restAfterSeconds, isWarmup = excluded.isWarmup, isDropset = excluded.isDropset, notes = excluded.notes`,
args: [
planSet.id,
planSet.planExerciseId,
planSet.setNumber,
planSet.reps ?? null,
planSet.weight ?? null,
planSet.durationSeconds ?? null,
planSet.rpe ?? null,
planSet.restAfterSeconds ?? null,
planSet.isWarmup ?? 0,
planSet.isDropset ?? 0,
planSet.notes ?? null
]
});
}
}
if (input.routePoints?.length) {
for (const point of input.routePoints) {
const planCheck = await conn.execute({
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
args: [point.planId]
});
if (
!planCheck.rows.length ||
planCheck.rows[0].userId !== ctx.nessaUserId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO routePoints (id, planId, latitude, longitude, orderIndex, isWaypoint)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET latitude = excluded.latitude, longitude = excluded.longitude, orderIndex = excluded.orderIndex, isWaypoint = excluded.isWaypoint`,
args: [
point.id,
point.planId,
point.latitude,
point.longitude,
point.orderIndex,
point.isWaypoint ?? 0
]
});
}
}
if (input.workouts?.length) {
for (const workout of input.workouts) {
if (workout.userId !== ctx.nessaUserId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO workouts (id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET planId = excluded.planId, type = excluded.type, name = excluded.name, startDate = excluded.startDate, endDate = excluded.endDate, durationSeconds = excluded.durationSeconds, distanceMeters = excluded.distanceMeters, calories = excluded.calories, averageHeartRate = excluded.averageHeartRate, maxHeartRate = excluded.maxHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, status = excluded.status, source = excluded.source, healthKitUUID = excluded.healthKitUUID, notes = excluded.notes, updatedAt = datetime('now')`,
args: [
workout.id,
workout.userId,
workout.planId ?? null,
workout.type,
workout.name ?? null,
workout.startDate,
workout.endDate ?? null,
workout.durationSeconds ?? null,
workout.distanceMeters ?? null,
workout.calories ?? null,
workout.averageHeartRate ?? null,
workout.maxHeartRate ?? null,
workout.averagePace ?? null,
workout.elevationGain ?? null,
workout.status,
workout.source,
workout.healthKitUUID ?? null,
workout.notes ?? null
]
});
}
}
if (input.heartRateSamples?.length) {
for (const sample of input.heartRateSamples) {
const workoutCheck = await conn.execute({
sql: "SELECT userId FROM workouts WHERE id = ?",
args: [sample.workoutId]
});
if (
!workoutCheck.rows.length ||
workoutCheck.rows[0].userId !== ctx.nessaUserId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO heartRateSamples (id, workoutId, timestamp, bpm, source)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, bpm = excluded.bpm, source = excluded.source`,
args: [
sample.id,
sample.workoutId,
sample.timestamp,
sample.bpm,
sample.source ?? null
]
});
}
}
if (input.locationSamples?.length) {
for (const sample of input.locationSamples) {
const workoutCheck = await conn.execute({
sql: "SELECT userId FROM workouts WHERE id = ?",
args: [sample.workoutId]
});
if (
!workoutCheck.rows.length ||
workoutCheck.rows[0].userId !== ctx.nessaUserId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO locationSamples (id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, latitude = excluded.latitude, longitude = excluded.longitude, altitude = excluded.altitude, horizontalAccuracy = excluded.horizontalAccuracy, verticalAccuracy = excluded.verticalAccuracy, speed = excluded.speed, course = excluded.course`,
args: [
sample.id,
sample.workoutId,
sample.timestamp,
sample.latitude,
sample.longitude,
sample.altitude ?? null,
sample.horizontalAccuracy ?? null,
sample.verticalAccuracy ?? null,
sample.speed ?? null,
sample.course ?? null
]
});
}
}
if (input.workoutSplits?.length) {
for (const split of input.workoutSplits) {
const workoutCheck = await conn.execute({
sql: "SELECT userId FROM workouts WHERE id = ?",
args: [split.workoutId]
});
if (
!workoutCheck.rows.length ||
workoutCheck.rows[0].userId !== ctx.nessaUserId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO workoutSplits (id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET splitNumber = excluded.splitNumber, distanceMeters = excluded.distanceMeters, durationSeconds = excluded.durationSeconds, startTimestamp = excluded.startTimestamp, endTimestamp = excluded.endTimestamp, averageHeartRate = excluded.averageHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, elevationLoss = excluded.elevationLoss`,
args: [
split.id,
split.workoutId,
split.splitNumber,
split.distanceMeters,
split.durationSeconds,
split.startTimestamp,
split.endTimestamp,
split.averageHeartRate ?? null,
split.averagePace ?? null,
split.elevationGain ?? null,
split.elevationLoss ?? null
]
});
}
}
if (input.authProviders?.length) {
for (const provider of input.authProviders) {
if (provider.userId !== ctx.nessaUserId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User mismatch"
});
}
await conn.execute({
sql: `INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, providerUserId = excluded.providerUserId, email = excluded.email, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, lastUsedAt = datetime('now')`,
args: [
provider.id,
provider.userId,
provider.provider,
provider.providerUserId ?? null,
provider.email ?? null,
provider.displayName ?? null,
provider.avatarUrl ?? null
]
});
}
}
await upsertUsers(conn, ctx.nessaUserId, input.users ?? []);
await upsertExerciseLibrary(
conn,
ctx.nessaUserId,
input.exerciseLibrary ?? []
);
await upsertWorkoutPlans(conn, ctx.nessaUserId, input.workoutPlans ?? []);
await upsertPlanExercises(
conn,
ctx.nessaUserId,
input.planExercises ?? []
);
await upsertPlanSets(conn, ctx.nessaUserId, input.planSets ?? []);
await upsertRoutePoints(conn, ctx.nessaUserId, input.routePoints ?? []);
await upsertWorkouts(conn, ctx.nessaUserId, input.workouts ?? []);
await upsertHeartRateSamples(
conn,
ctx.nessaUserId,
input.heartRateSamples ?? []
);
await upsertLocationSamples(
conn,
ctx.nessaUserId,
input.locationSamples ?? []
);
await upsertWorkoutSplits(
conn,
ctx.nessaUserId,
input.workoutSplits ?? []
);
await upsertAuthProviders(
conn,
ctx.nessaUserId,
input.authProviders ?? []
);
return { success: true };
} catch (error) {

View File

@@ -4,7 +4,7 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import diff from "fast-diff";
export function createDiffPatch(
function createDiffPatch(
oldContent: string,
newContent: string
): string {
@@ -12,7 +12,7 @@ export function createDiffPatch(
return JSON.stringify(changes);
}
export function applyDiffPatch(baseContent: string, patchJson: string): string {
function applyDiffPatch(baseContent: string, patchJson: string): string {
const changes = JSON.parse(patchJson);
let result = "";
let position = 0;
@@ -96,7 +96,6 @@ export const postHistoryRouter = createTRPCRouter({
const conn = ConnectionFactory();
// Verify post exists and user is author
const postCheck = await conn.execute({
sql: "SELECT author_id FROM Post WHERE id = ?",
args: [input.postId]

View File

@@ -254,7 +254,6 @@ export const userRouter = createTRPCRouter({
args: [passwordHash, userId]
});
// Send email notification about password being set
if (user.email) {
try {
const h3Event = ctx.event.nativeEvent

View File

@@ -6,105 +6,6 @@ import { z } from "zod";
* Schemas for post creation, updating, querying, and interactions
*/
// ============================================================================
// Post Category and Status
// ============================================================================
/**
* Post category enum (deprecated but kept for backward compatibility)
*/
export const postCategorySchema = z.enum(["blog", "project"]);
// ============================================================================
// Post Creation and Updates
// ============================================================================
/**
* Create new post schema
*/
export const createPostSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(200, "Title must be under 200 characters"),
subtitle: z
.string()
.max(300, "Subtitle must be under 300 characters")
.optional(),
body: z.string().min(1, "Post body is required"),
banner_photo: z.string().url("Must be a valid URL").optional(),
published: z.boolean().default(false),
category: postCategorySchema.default("blog"),
attachments: z.string().optional()
});
/**
* Update post schema (partial updates)
*/
export const updatePostSchema = z.object({
postId: z.number(),
title: z.string().min(1).max(200).optional(),
subtitle: z.string().max(300).optional(),
body: z.string().min(1).optional(),
banner_photo: z.string().url().optional(),
published: z.boolean().optional(),
attachments: z.string().optional()
});
/**
* Delete post schema
*/
export const deletePostSchema = z.object({
postId: z.number()
});
// ============================================================================
// Post Queries and Filtering
// ============================================================================
/**
* Post sort mode enum
* Defines available sorting options for blog posts
*/
export const postSortModeSchema = z.enum([
"newest",
"oldest",
"most_liked",
"most_read",
"most_comments"
]);
/**
* Post query input schema
* Accepts optional filters (pipe-separated tags) and sort mode
*/
export const postQueryInputSchema = z.object({
/**
* Pipe-separated list of tags to filter by
* e.g., "tech|design|javascript"
* Empty string or undefined means no filter
*/
filters: z.string().optional(),
/**
* Sort mode for posts
* Defaults to "newest" if not specified
*/
sortBy: postSortModeSchema.default("newest")
});
/**
* Get single post by ID or slug
*/
export const getPostSchema = z
.object({
postId: z.number().optional(),
slug: z.string().optional()
})
.refine((data) => data.postId || data.slug, {
message: "Either postId or slug must be provided"
});
// ============================================================================
// Post Interactions
// ============================================================================
@@ -116,55 +17,8 @@ export const incrementPostReadSchema = z.object({
postId: z.number()
});
/**
* Like/unlike post
*/
export const togglePostLikeSchema = z.object({
postId: z.number()
});
// ============================================================================
// Tag Management
// ============================================================================
/**
* Add tags to post
*/
export const addTagsToPostSchema = z.object({
postId: z.number(),
tags: z
.array(z.string().min(1).max(50))
.min(1, "At least one tag is required")
});
/**
* Remove tag from post
*/
export const removeTagFromPostSchema = z.object({
tagId: z.number()
});
/**
* Update post tags (replaces all tags)
*/
export const updatePostTagsSchema = z.object({
postId: z.number(),
tags: z.array(z.string().min(1).max(50))
});
// ============================================================================
// Type Exports
// ============================================================================
export type PostCategory = z.infer<typeof postCategorySchema>;
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export type DeletePostInput = z.infer<typeof deletePostSchema>;
export type PostSortMode = z.infer<typeof postSortModeSchema>;
export type PostQueryInput = z.infer<typeof postQueryInputSchema>;
export type GetPostInput = z.infer<typeof getPostSchema>;
export type IncrementPostReadInput = z.infer<typeof incrementPostReadSchema>;
export type TogglePostLikeInput = z.infer<typeof togglePostLikeSchema>;
export type AddTagsToPostInput = z.infer<typeof addTagsToPostSchema>;
export type RemoveTagFromPostInput = z.infer<typeof removeTagFromPostSchema>;
export type UpdatePostTagsInput = z.infer<typeof updatePostTagsSchema>;

View File

@@ -1,116 +0,0 @@
/**
* Comment API Validation Schemas
*
* Zod schemas for comment-related tRPC procedures:
* - Comment creation, updating, deletion
* - Comment reactions
* - Comment sorting and filtering
*/
import { z } from "zod";
// ============================================================================
// Comment CRUD Operations
// ============================================================================
/**
* Create new comment schema
*/
export const createCommentSchema = z.object({
body: z
.string()
.min(1, "Comment cannot be empty")
.max(5000, "Comment too long"),
post_id: z.number(),
parent_comment_id: z.number().optional()
});
/**
* Update comment schema
*/
export const updateCommentSchema = z.object({
commentId: z.number(),
body: z
.string()
.min(1, "Comment cannot be empty")
.max(5000, "Comment too long")
});
/**
* Delete comment schema
*/
export const deleteCommentSchema = z.object({
commentId: z.number(),
deletionType: z.enum(["user", "admin", "database"]).optional()
});
/**
* Get comments for post schema
*/
export const getCommentsSchema = z.object({
postId: z.number(),
sortBy: z.enum(["newest", "oldest", "highest_rated", "hot"]).default("newest")
});
// ============================================================================
// Comment Reactions
// ============================================================================
/**
* Valid reaction types
*/
export const reactionTypeSchema = z.enum([
"tears",
"blank",
"tongue",
"cry",
"heartEye",
"angry",
"moneyEye",
"sick",
"upsideDown",
"worried"
]);
/**
* Add/remove reaction to comment
*/
export const toggleCommentReactionSchema = z.object({
commentId: z.number(),
reactionType: reactionTypeSchema
});
/**
* Get reactions for comment
*/
export const getCommentReactionsSchema = z.object({
commentId: z.number()
});
// ============================================================================
// Comment Sorting
// ============================================================================
/**
* Valid comment sorting modes
*/
export const commentSortSchema = z
.enum(["newest", "oldest", "highest_rated", "hot"])
.default("newest");
// ============================================================================
// Type Exports
// ============================================================================
export type CommentSortMode = z.infer<typeof commentSortSchema>;
export type ReactionType = z.infer<typeof reactionTypeSchema>;
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
export type DeleteCommentInput = z.infer<typeof deleteCommentSchema>;
export type GetCommentsInput = z.infer<typeof getCommentsSchema>;
export type ToggleCommentReactionInput = z.infer<
typeof toggleCommentReactionSchema
>;
export type GetCommentReactionsInput = z.infer<
typeof getCommentReactionsSchema
>;

View File

@@ -7,71 +7,10 @@ import { z } from "zod";
* Use these schemas for validating database inputs and outputs in tRPC procedures
*/
// ============================================================================
// User Schemas
// ============================================================================
/**
* Full User schema matching database structure
*/
export const userSchema = z.object({
id: z.string(),
email: z.string().email().nullable().optional(),
email_verified: z.number(),
password_hash: z.string().nullable().optional(),
display_name: z.string().nullable().optional(),
provider: z.enum(["email", "google", "github"]).nullable().optional(),
image: z.string().url().nullable().optional(),
apple_user_string: z.string().nullable().optional(),
database_name: z.string().nullable().optional(),
database_token: z.string().nullable().optional(),
database_url: z.string().nullable().optional(),
db_destroy_date: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string()
});
/**
* User creation input (for registration)
*/
export const createUserSchema = z.object({
email: z.string().email().optional(),
password: z.string().min(8).optional(),
display_name: z.string().min(1).max(50).optional(),
provider: z.enum(["email", "google", "github"]).optional(),
image: z.string().url().optional()
});
/**
* User update input (partial updates)
*/
export const updateUserSchema = z.object({
email: z.string().email().optional(),
display_name: z.string().min(1).max(50).optional(),
image: z.string().url().optional()
});
// ============================================================================
// Post Schemas
// ============================================================================
/**
* Full Post schema matching database structure
*/
export const postSchema = z.object({
id: z.number(),
category: z.enum(["blog", "project"]),
title: z.string(),
subtitle: z.string().optional(),
body: z.string(),
banner_photo: z.string().optional(),
date: z.string(),
published: z.boolean(),
author_id: z.string(),
reads: z.number(),
attachments: z.string().optional()
});
/**
* Post creation input
*/
@@ -97,47 +36,6 @@ export const updatePostSchema = z.object({
attachments: z.string().optional()
});
/**
* Post with aggregated data
*/
export const postWithCommentsAndLikesSchema = postSchema.extend({
total_likes: z.number(),
total_comments: z.number()
});
// ============================================================================
// Comment Schemas
// ============================================================================
/**
* Full Comment schema matching database structure
*/
export const commentSchema = z.object({
id: z.number(),
body: z.string(),
post_id: z.number(),
parent_comment_id: z.number().optional(),
date: z.string(),
edited: z.boolean(),
commenter_id: z.string()
});
/**
* Comment creation input
*/
export const createCommentSchema = z.object({
body: z.string().min(1).max(5000),
post_id: z.number(),
parent_comment_id: z.number().optional()
});
/**
* Comment update input
*/
export const updateCommentSchema = z.object({
body: z.string().min(1).max(5000)
});
// ============================================================================
// CommentReaction Schemas
// ============================================================================
@@ -160,94 +58,6 @@ export const reactionTypeSchema = z.enum([
"downVote"
]);
/**
* Full CommentReaction schema matching database structure
*/
export const commentReactionSchema = z.object({
id: z.number(),
type: reactionTypeSchema,
comment_id: z.number(),
user_id: z.string()
});
/**
* Comment reaction creation input
*/
export const createCommentReactionSchema = z.object({
type: reactionTypeSchema,
comment_id: z.number()
});
// ============================================================================
// PostLike Schemas
// ============================================================================
/**
* Full PostLike schema matching database structure
*/
export const postLikeSchema = z.object({
id: z.number(),
user_id: z.string(),
post_id: z.number()
});
/**
* PostLike creation input
*/
export const createPostLikeSchema = z.object({
post_id: z.number()
});
// ============================================================================
// Tag Schemas
// ============================================================================
/**
* Full Tag schema matching database structure
*/
export const tagSchema = z.object({
id: z.number(),
value: z.string(),
post_id: z.number()
});
/**
* Tag creation input
*/
export const createTagSchema = z.object({
value: z.string().min(1).max(50),
post_id: z.number()
});
/**
* PostWithTags schema
*/
export const postWithTagsSchema = postSchema.extend({
tags: z.array(tagSchema)
});
// ============================================================================
// Connection Schemas
// ============================================================================
/**
* Full Connection schema matching database structure
*/
export const connectionSchema = z.object({
id: z.number(),
user_id: z.string(),
connection_id: z.string(),
post_id: z.number().optional()
});
/**
* Connection creation input
*/
export const createConnectionSchema = z.object({
connection_id: z.string(),
post_id: z.number().optional()
});
// ============================================================================
// Common Query Schemas
// ============================================================================
@@ -259,26 +69,6 @@ export const idSchema = z.object({
id: z.number()
});
export const userIdSchema = z.object({
userId: z.string()
});
export const postIdSchema = z.object({
postId: z.number()
});
export const commentIdSchema = z.object({
commentId: z.number()
});
/**
* Pagination schema
*/
export const paginationSchema = z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0)
});
// ============================================================================
// Additional Database Router Schemas
// ============================================================================
@@ -343,10 +133,6 @@ export const getUserByIdSchema = z.object({
id: z.string()
});
export const getUserPublicDataSchema = z.object({
id: z.string()
});
export const updateUserImageSchema = z.object({
id: z.string(),
imageURL: z.string()
@@ -365,15 +151,6 @@ export const updateUserEmailSchema = z.object({
export type ReactionType = z.infer<typeof reactionTypeSchema>;
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
export type CreateCommentReactionInput = z.infer<
typeof createCommentReactionSchema
>;
export type CreatePostLikeInput = z.infer<typeof createPostLikeSchema>;
export type CreateTagInput = z.infer<typeof createTagSchema>;
export type CreateConnectionInput = z.infer<typeof createConnectionSchema>;
export type PaginationInput = z.infer<typeof paginationSchema>;
export type GetPostByIdInput = z.infer<typeof getPostByIdSchema>;
export type GetPostByTitleInput = z.infer<typeof getPostByTitleSchema>;
export type GetCommentsByPostIdInput = z.infer<

View File

@@ -65,11 +65,6 @@ export const loginUserSchema = z.object({
rememberMe: z.boolean().optional().default(false)
});
/**
* OAuth provider schema
*/
export const oauthProviderSchema = z.enum(["google", "github"]);
// ============================================================================
// Profile Management Schemas
// ============================================================================
@@ -168,20 +163,12 @@ export const deleteAccountSchema = z.object({
password: z.string().min(1, "Password is required to delete account")
});
/**
* Email verification schema
*/
export const verifyEmailSchema = z.object({
token: z.string().min(1)
});
// ============================================================================
// Type Exports
// ============================================================================
export type RegisterUserInput = z.infer<typeof registerUserSchema>;
export type LoginUserInput = z.infer<typeof loginUserSchema>;
export type OAuthProvider = z.infer<typeof oauthProviderSchema>;
export type UpdateEmailInput = z.infer<typeof updateEmailSchema>;
export type UpdateDisplayNameInput = z.infer<typeof updateDisplayNameSchema>;
export type UpdateProfileImageInput = z.infer<typeof updateProfileImageSchema>;
@@ -192,4 +179,3 @@ export type RequestPasswordResetInput = z.infer<
>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type DeleteAccountInput = z.infer<typeof deleteAccountSchema>;
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>;