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:
@@ -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")
|
||||
) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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<
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user