feat: add Nessa club events router (CRUD/RSVP/participants), suppress expected JWT verify logs, drop unused GOOGLE_CLIENT_ID env

This commit is contained in:
2026-07-22 22:23:44 -04:00
parent ff956be80f
commit 42757bc93d
4 changed files with 403 additions and 58 deletions

2
src/env/server.ts vendored
View File

@@ -49,7 +49,6 @@ const serverEnvSchema = z.object({
VITE_DOWNLOAD_BUCKET_STRING: z.string().min(1), VITE_DOWNLOAD_BUCKET_STRING: z.string().min(1),
VITE_GOOGLE_CLIENT_ID: z.string().min(1), VITE_GOOGLE_CLIENT_ID: z.string().min(1),
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1), VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1),
GOOGLE_CLIENT_ID: z.string().min(1),
VITE_GITHUB_CLIENT_ID: z.string().min(1), VITE_GITHUB_CLIENT_ID: z.string().min(1),
VITE_WEBSOCKET: z.string().min(1), VITE_WEBSOCKET: z.string().min(1),
VITE_INFILL_ENDPOINT: z.string().min(1), VITE_INFILL_ENDPOINT: z.string().min(1),
@@ -164,7 +163,6 @@ export const getMissingEnvVars = (): string[] => {
"VITE_DOWNLOAD_BUCKET_STRING", "VITE_DOWNLOAD_BUCKET_STRING",
"VITE_GOOGLE_CLIENT_ID", "VITE_GOOGLE_CLIENT_ID",
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"GOOGLE_CLIENT_ID",
"VITE_GITHUB_CLIENT_ID", "VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET", "VITE_WEBSOCKET",
"REDIS_URL", "REDIS_URL",

View File

@@ -1,4 +1,8 @@
import { createTRPCRouter, publicProcedure, csrfProtectedProcedure } from "../../utils"; import {
createTRPCRouter,
publicProcedure,
csrfProtectedProcedure
} from "../../utils";
import { z } from "zod"; import { z } from "zod";
import { import {
LineageConnectionFactory, LineageConnectionFactory,
@@ -6,7 +10,7 @@ import {
hashPassword, hashPassword,
checkPassword, checkPassword,
sendEmailVerification, sendEmailVerification,
LINEAGE_JWT_EXPIRY, LINEAGE_JWT_EXPIRY
} from "~/server/utils"; } from "~/server/utils";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { LINEAGE_CONFIG } from "~/config"; import { LINEAGE_CONFIG } from "~/config";
@@ -20,7 +24,7 @@ export const lineageAuthRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), password: z.string().min(8)
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -34,7 +38,7 @@ export const lineageAuthRouter = createTRPCRouter({
if (res.rows.length === 0) { if (res.rows.length === 0) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid Credentials", message: "Invalid Credentials"
}); });
} }
@@ -43,7 +47,7 @@ export const lineageAuthRouter = createTRPCRouter({
if (user.email_verified === 0) { if (user.email_verified === 0) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Email not yet verified!", message: "Email not yet verified!"
}); });
} }
@@ -51,7 +55,7 @@ export const lineageAuthRouter = createTRPCRouter({
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid Credentials", message: "Invalid Credentials"
}); });
} }
@@ -70,7 +74,7 @@ export const lineageAuthRouter = createTRPCRouter({
success: true, success: true,
message: "Login successful", message: "Login successful",
token, token,
email, email
}; };
}), }),
@@ -79,7 +83,7 @@ export const lineageAuthRouter = createTRPCRouter({
z.object({ z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), password: z.string().min(8),
password_conf: z.string().min(8), password_conf: z.string().min(8)
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -88,7 +92,7 @@ export const lineageAuthRouter = createTRPCRouter({
if (password !== password_conf) { if (password !== password_conf) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Password mismatch", message: "Password mismatch"
}); });
} }
@@ -107,12 +111,12 @@ export const lineageAuthRouter = createTRPCRouter({
if (emailResult.success && emailResult.messageId) { if (emailResult.success && emailResult.messageId) {
return { return {
success: true, success: true,
message: "Email verification sent!", message: "Email verification sent!"
}; };
} else { } else {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: emailResult.message || "Failed to send verification email", message: emailResult.message || "Failed to send verification email"
}); });
} }
} catch (e) { } catch (e) {
@@ -120,13 +124,13 @@ export const lineageAuthRouter = createTRPCRouter({
if (e instanceof LibsqlError && e.code === "SQLITE_CONSTRAINT") { if (e instanceof LibsqlError && e.code === "SQLITE_CONSTRAINT") {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "User already exists", message: "User already exists"
}); });
} }
if (e instanceof TRPCError) throw e; if (e instanceof TRPCError) throw e;
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while creating the user", message: "An error occurred while creating the user"
}); });
} }
}), }),
@@ -135,7 +139,7 @@ export const lineageAuthRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
email: z.string().email(), email: z.string().email(),
token: z.string(), token: z.string()
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -152,13 +156,13 @@ export const lineageAuthRouter = createTRPCRouter({
const secret = new TextEncoder().encode(env.LINEAGE_JWT_SECRET); const secret = new TextEncoder().encode(env.LINEAGE_JWT_SECRET);
const { payload } = await jwtVerify(token, secret, { const { payload } = await jwtVerify(token, secret, {
issuer: LINEAGE_CONFIG.JWT_ISSUER, issuer: LINEAGE_CONFIG.JWT_ISSUER,
audience: LINEAGE_CONFIG.JWT_AUDIENCE, audience: LINEAGE_CONFIG.JWT_AUDIENCE
}); });
if (payload.email !== userEmail) { if (payload.email !== userEmail) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Authentication failed: email mismatch", message: "Authentication failed: email mismatch"
}); });
} }
@@ -178,7 +182,7 @@ export const lineageAuthRouter = createTRPCRouter({
return { return {
success: true, success: true,
message: message:
"Email verification success. You may close this window and sign in within the app.", "Email verification success. You may close this window and sign in within the app."
}; };
} catch (err) { } catch (err) {
console.error("Error in email verification:", err); console.error("Error in email verification:", err);
@@ -187,7 +191,7 @@ export const lineageAuthRouter = createTRPCRouter({
try { try {
const turso = createAPIClient({ const turso = createAPIClient({
org: "mikefreno", org: "mikefreno",
token: env.TURSO_DB_API_TOKEN, token: env.TURSO_DB_API_TOKEN
}); });
await turso.databases.delete(dbName); await turso.databases.delete(dbName);
console.log(`Database ${dbName} deleted due to error`); console.log(`Database ${dbName} deleted due to error`);
@@ -200,7 +204,7 @@ export const lineageAuthRouter = createTRPCRouter({
try { try {
await conn.execute({ await conn.execute({
sql: `UPDATE User SET email_verified = ?, database_name = ?, database_token = ? WHERE email = ?`, sql: `UPDATE User SET email_verified = ?, database_name = ?, database_token = ? WHERE email = ?`,
args: [false, null, null, userEmail], args: [false, null, null, userEmail]
}); });
console.log("User table update reverted"); console.log("User table update reverted");
} catch (revertErr) { } catch (revertErr) {
@@ -212,7 +216,7 @@ export const lineageAuthRouter = createTRPCRouter({
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: message:
"Authentication failed: An error occurred during email verification. Please try again.", "Authentication failed: An error occurred during email verification. Please try again."
}); });
} }
}), }),
@@ -230,7 +234,7 @@ export const lineageAuthRouter = createTRPCRouter({
if (res.rows.length === 0 || res.rows[0].email_verified) { if (res.rows.length === 0 || res.rows[0].email_verified) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Invalid Request", message: "Invalid Request"
}); });
} }
@@ -238,12 +242,12 @@ export const lineageAuthRouter = createTRPCRouter({
if (emailResult.success && emailResult.messageId) { if (emailResult.success && emailResult.messageId) {
return { return {
success: true, success: true,
message: "Email verification sent!", message: "Email verification sent!"
}; };
} else { } else {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: emailResult.message || "Failed to send verification email", message: emailResult.message || "Failed to send verification email"
}); });
} }
}), }),
@@ -257,12 +261,12 @@ export const lineageAuthRouter = createTRPCRouter({
const secret = new TextEncoder().encode(env.LINEAGE_JWT_SECRET); const secret = new TextEncoder().encode(env.LINEAGE_JWT_SECRET);
const { payload } = await jwtVerify(token, secret, { const { payload } = await jwtVerify(token, secret, {
issuer: LINEAGE_CONFIG.JWT_ISSUER, issuer: LINEAGE_CONFIG.JWT_ISSUER,
audience: LINEAGE_CONFIG.JWT_AUDIENCE, audience: LINEAGE_CONFIG.JWT_AUDIENCE
}); });
const newToken = await new SignJWT({ const newToken = await new SignJWT({
userId: payload.userId, userId: payload.userId,
email: payload.email, email: payload.email
}) })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setIssuer(LINEAGE_CONFIG.JWT_ISSUER) .setIssuer(LINEAGE_CONFIG.JWT_ISSUER)
@@ -275,12 +279,12 @@ export const lineageAuthRouter = createTRPCRouter({
ok: true, ok: true,
valid: true, valid: true,
token: newToken, token: newToken,
email: payload.email, email: payload.email
}; };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid or expired token", message: "Invalid or expired token"
}); });
} }
}), }),
@@ -296,7 +300,7 @@ export const lineageAuthRouter = createTRPCRouter({
const checkUserQuery = "SELECT * FROM User WHERE email = ?"; const checkUserQuery = "SELECT * FROM User WHERE email = ?";
const checkUserResult = await conn.execute({ const checkUserResult = await conn.execute({
sql: checkUserQuery, sql: checkUserQuery,
args: [email], args: [email]
}); });
if (checkUserResult.rows.length > 0) { if (checkUserResult.rows.length > 0) {
@@ -307,18 +311,18 @@ export const lineageAuthRouter = createTRPCRouter({
`; `;
const updateRes = await conn.execute({ const updateRes = await conn.execute({
sql: updateQuery, sql: updateQuery,
args: ["google", email], args: ["google", email]
}); });
if (updateRes.rowsAffected !== 0) { if (updateRes.rowsAffected !== 0) {
return { return {
success: true, success: true,
message: "User information updated", message: "User information updated"
}; };
} else { } else {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "User update failed!", message: "User update failed!"
}); });
} }
} else { } else {
@@ -333,27 +337,27 @@ export const lineageAuthRouter = createTRPCRouter({
`; `;
await conn.execute({ await conn.execute({
sql: insertQuery, sql: insertQuery,
args: [email, true, "google", dbName, token], args: [email, true, "google", dbName, token]
}); });
console.log("insert success"); console.log("insert success");
return { return {
success: true, success: true,
message: "New user created", message: "New user created"
}; };
} catch (error) { } catch (error) {
if (db_name) { if (db_name) {
const turso = createAPIClient({ const turso = createAPIClient({
org: "mikefreno", org: "mikefreno",
token: env.TURSO_DB_API_TOKEN, token: env.TURSO_DB_API_TOKEN
}); });
await turso.databases.delete(db_name); await turso.databases.delete(db_name);
} }
console.error(error); console.error(error);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to create user", message: "Failed to create user"
}); });
} }
} }
@@ -362,7 +366,7 @@ export const lineageAuthRouter = createTRPCRouter({
if (error instanceof TRPCError) throw error; if (error instanceof TRPCError) throw error;
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while processing the request", message: "An error occurred while processing the request"
}); });
} }
}), }),
@@ -371,7 +375,7 @@ export const lineageAuthRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
email: z.string().email().optional(), email: z.string().email().optional(),
idToken: z.string(), idToken: z.string()
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -384,7 +388,7 @@ export const lineageAuthRouter = createTRPCRouter({
if (!appleKeysResponse.ok) { if (!appleKeysResponse.ok) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch Apple public keys", message: "Failed to fetch Apple public keys"
}); });
} }
@@ -404,7 +408,7 @@ export const lineageAuthRouter = createTRPCRouter({
if (!headerB64) { if (!headerB64) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid Apple ID token format", message: "Invalid Apple ID token format"
}); });
} }
const headerJson = Buffer.from(headerB64, "base64url").toString("utf8"); const headerJson = Buffer.from(headerB64, "base64url").toString("utf8");
@@ -413,14 +417,14 @@ export const lineageAuthRouter = createTRPCRouter({
if (!jwk) { if (!jwk) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Apple public key not found", message: "Apple public key not found"
}); });
} }
const publicKey = await importJWK(jwk, "RS256"); const publicKey = await importJWK(jwk, "RS256");
const jwtOptions: Parameters<typeof jwtVerify>[2] = { const jwtOptions: Parameters<typeof jwtVerify>[2] = {
algorithms: ["RS256"], algorithms: ["RS256"],
issuer: "https://appleid.apple.com", issuer: "https://appleid.apple.com"
}; };
if (env.APPLE_CLIENT_ID) { if (env.APPLE_CLIENT_ID) {
jwtOptions.audience = env.APPLE_CLIENT_ID; jwtOptions.audience = env.APPLE_CLIENT_ID;
@@ -441,14 +445,14 @@ export const lineageAuthRouter = createTRPCRouter({
try { try {
let checkUserQuery = "SELECT * FROM User WHERE apple_user_string = ?"; let checkUserQuery = "SELECT * FROM User WHERE apple_user_string = ?";
let args: string[] = [userString]; const args: string[] = [userString];
if (email) { if (email) {
args.push(email); args.push(email);
checkUserQuery += " OR email = ?"; checkUserQuery += " OR email = ?";
} }
const checkUserResult = await conn.execute({ const checkUserResult = await conn.execute({
sql: checkUserQuery, sql: checkUserQuery,
args: args, args: args
}); });
if (checkUserResult.rows.length > 0) { if (checkUserResult.rows.length > 0) {
@@ -474,19 +478,19 @@ export const lineageAuthRouter = createTRPCRouter({
)} ${whereClause}`; )} ${whereClause}`;
const updateRes = await conn.execute({ const updateRes = await conn.execute({
sql: updateQuery, sql: updateQuery,
args: values, args: values
}); });
if (updateRes.rowsAffected !== 0) { if (updateRes.rowsAffected !== 0) {
return { return {
success: true, success: true,
message: "User information updated", message: "User information updated",
email: checkUserResult.rows[0].email as string, email: checkUserResult.rows[0].email as string
}; };
} else { } else {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "User update failed!", message: "User update failed!"
}); });
} }
} else { } else {
@@ -501,27 +505,27 @@ export const lineageAuthRouter = createTRPCRouter({
`; `;
await conn.execute({ await conn.execute({
sql: insertQuery, sql: insertQuery,
args: [email, true, userString, "apple", dbName, dbToken], args: [email, true, userString, "apple", dbName, dbToken]
}); });
return { return {
success: true, success: true,
message: "New user created", message: "New user created",
dbName, dbName,
dbToken, dbToken
}; };
} catch (error) { } catch (error) {
if (dbName) { if (dbName) {
const turso = createAPIClient({ const turso = createAPIClient({
org: "mikefreno", org: "mikefreno",
token: env.TURSO_DB_API_TOKEN, token: env.TURSO_DB_API_TOKEN
}); });
await turso.databases.delete(dbName); await turso.databases.delete(dbName);
} }
console.error(error); console.error(error);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to create user", message: "Failed to create user"
}); });
} }
} }
@@ -530,7 +534,7 @@ export const lineageAuthRouter = createTRPCRouter({
try { try {
const turso = createAPIClient({ const turso = createAPIClient({
org: "mikefreno", org: "mikefreno",
token: env.TURSO_DB_API_TOKEN, token: env.TURSO_DB_API_TOKEN
}); });
await turso.databases.delete(dbName); await turso.databases.delete(dbName);
} catch (deleteErr) { } catch (deleteErr) {
@@ -541,7 +545,7 @@ export const lineageAuthRouter = createTRPCRouter({
if (error instanceof TRPCError) throw error; if (error instanceof TRPCError) throw error;
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while processing the request", message: "An error occurred while processing the request"
}); });
} }
}), }),
@@ -560,8 +564,8 @@ export const lineageAuthRouter = createTRPCRouter({
} else { } else {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "User not found", message: "User not found"
}); });
} }
}), })
}); });

View File

@@ -1272,5 +1272,333 @@ export const nessaCommunityRouter = createTRPCRouter({
}); });
} }
}) })
}),
// ==========================================================================
// Events (clubEvents)
// ==========================================================================
events: createTRPCRouter({
list: nessaProcedure
.input(paginationSchema.extend({
clubId: z.string().min(1).optional(),
eventType: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
location: z.string().optional(),
rsvpStatus: z.enum(["going", "maybe", "not-going"]).optional()
}))
.query(async ({ input, ctx }) => {
const limit = input.limit ?? 50;
const offset = input.offset ?? 0;
try {
const conn = NessaConnectionFactory();
const where: string[] = [];
const args: (string | number)[] = [];
if (input.clubId) {
where.push("e.clubId = ?");
args.push(input.clubId);
}
if (input.eventType) {
where.push("e.eventType = ?");
args.push(input.eventType);
}
if (input.startDate) {
where.push("e.startDate >= ?");
args.push(input.startDate);
}
if (input.endDate) {
where.push("e.startDate <= ?");
args.push(input.endDate);
}
if (input.location) {
where.push("(e.location LIKE ?)");
args.push(`%${input.location}%`);
}
const whereClause = where.length
? `WHERE ${where.join(" AND ")}`
: "";
args.push(limit, offset);
const result = await conn.execute({
sql: `SELECT e.id, e.clubId, e.title, e.description, e.eventType,
e.location, e.latitude, e.longitude, e.startDate, e.endDate,
e.createdBy, e.maxParticipants, e.participantCount,
e.createdAt, e.updatedAt,
u.displayName AS creatorDisplayName, u.avatarUrl AS creatorAvatarUrl,
(SELECT COUNT(*) FROM clubEventRSVPs WHERE eventId = e.id) AS rsvpCount,
(SELECT status FROM clubEventRSVPs WHERE eventId = e.id AND userId = ?) AS userRsvpStatus
FROM clubEvents e
JOIN users u ON e.createdBy = u.id
${whereClause}
ORDER BY e.startDate ASC LIMIT ? OFFSET ?`,
args: [...args, ctx.nessaUserId]
});
return { events: result.rows };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error("Failed to list Nessa events:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to list events"
});
}
}),
get: nessaProcedure
.input(idSchema)
.query(async ({ input, ctx }) => {
try {
const conn = NessaConnectionFactory();
const result = await conn.execute({
sql: `SELECT e.id, e.clubId, e.title, e.description, e.eventType,
e.location, e.latitude, e.longitude, e.startDate, e.endDate,
e.createdBy, e.maxParticipants, e.participantCount,
e.createdAt, e.updatedAt,
u.displayName AS creatorDisplayName, u.avatarUrl AS creatorAvatarUrl,
(SELECT COUNT(*) FROM clubEventRSVPs WHERE eventId = e.id) AS rsvpCount,
(SELECT status FROM clubEventRSVPs WHERE eventId = e.id AND userId = ?) AS userRsvpStatus
FROM clubEvents e
JOIN users u ON e.createdBy = u.id
WHERE e.id = ?`,
args: [ctx.nessaUserId, input.id]
});
if (!result.rows.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
return { event: result.rows[0] };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error("Failed to get Nessa event:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get event"
});
}
}),
create: nessaProcedure
.input(z.object({
clubId: z.string().min(1),
title: z.string().min(1).max(200),
description: z.string().max(2000).nullable().optional(),
eventType: z.string().min(1),
location: z.string().nullable().optional(),
latitude: z.number().nullable().optional(),
longitude: z.number().nullable().optional(),
startDate: z.string().min(1),
endDate: z.string().nullable().optional(),
maxParticipants: z.number().int().min(1).nullable().optional()
}))
.mutation(async ({ input, ctx }) => {
try {
const conn = NessaConnectionFactory();
await requireClubMembership(conn, input.clubId, ctx.nessaUserId);
const eventId = crypto.randomUUID();
await conn.execute({
sql: `INSERT INTO clubEvents
(id, clubId, title, description, eventType, location, latitude, longitude,
startDate, endDate, createdBy, maxParticipants)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
eventId,
input.clubId,
input.title,
input.description ?? null,
input.eventType,
input.location ?? null,
input.latitude ?? null,
input.longitude ?? null,
input.startDate,
input.endDate ?? null,
ctx.nessaUserId,
input.maxParticipants ?? null
]
});
return { success: true, eventId };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error("Failed to create Nessa event:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create event"
});
}
}),
update: nessaProcedure
.input(z.object({
id: z.string().min(1),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
eventType: z.string().min(1).optional(),
location: z.string().nullable().optional(),
latitude: z.number().nullable().optional(),
longitude: z.number().nullable().optional(),
startDate: z.string().min(1).optional(),
endDate: z.string().nullable().optional(),
maxParticipants: z.number().int().min(1).nullable().optional()
}))
.mutation(async ({ input, ctx }) => {
try {
const conn = NessaConnectionFactory();
const ownerCheck = await conn.execute({
sql: "SELECT createdBy FROM clubEvents WHERE id = ?",
args: [input.id]
});
if (!ownerCheck.rows.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
if ((ownerCheck.rows[0] as unknown as { createdBy: string }).createdBy !== ctx.nessaUserId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the creator can update the event"
});
}
const fields: string[] = [];
const args: (string | number | null)[] = [];
const map: Record<string, string> = {
title: "title",
description: "description",
eventType: "eventType",
location: "location",
latitude: "latitude",
longitude: "longitude",
startDate: "startDate",
endDate: "endDate",
maxParticipants: "maxParticipants"
};
for (const [key, col] of Object.entries(map)) {
if ((input as Record<string, unknown>)[key] !== undefined) {
fields.push(`${col} = ?`);
args.push((input as Record<string, unknown>)[key] as string | number | null);
}
}
if (fields.length === 0) {
return { success: true };
}
fields.push("updatedAt = datetime('now')");
args.push(input.id);
await conn.execute({
sql: `UPDATE clubEvents SET ${fields.join(", ")} WHERE id = ?`,
args
});
return { success: true };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error("Failed to update Nessa event:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update event"
});
}
}),
delete: nessaProcedure
.input(idSchema)
.mutation(async ({ input, ctx }) => {
try {
const conn = NessaConnectionFactory();
const ownerCheck = await conn.execute({
sql: "SELECT createdBy FROM clubEvents WHERE id = ?",
args: [input.id]
});
if (!ownerCheck.rows.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
if ((ownerCheck.rows[0] as unknown as { createdBy: string }).createdBy !== ctx.nessaUserId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the creator can delete the event"
});
}
await conn.execute({
sql: "DELETE FROM clubEvents WHERE id = ?",
args: [input.id]
});
return { success: true };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error("Failed to delete Nessa event:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete event"
});
}
}),
rsvp: nessaProcedure
.input(z.object({
eventId: z.string().min(1),
status: z.enum(["going", "maybe", "not-going"]).default("going")
}))
.mutation(async ({ input, ctx }) => {
try {
const conn = NessaConnectionFactory();
const event = await conn.execute({
sql: "SELECT clubId FROM clubEvents WHERE id = ?",
args: [input.eventId]
});
if (!event.rows.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
}
const clubId = (event.rows[0] as unknown as { clubId: string }).clubId;
await requireClubMembership(conn, clubId, ctx.nessaUserId);
// Delete existing RSVP if any
await conn.execute({
sql: "DELETE FROM clubEventRSVPs WHERE eventId = ? AND userId = ?",
args: [input.eventId, ctx.nessaUserId]
});
await conn.execute({
sql: "INSERT INTO clubEventRSVPs (id, eventId, userId, status) VALUES (?, ?, ?, ?)",
args: [crypto.randomUUID(), input.eventId, ctx.nessaUserId, input.status]
});
return { success: true, eventId: input.eventId, userId: ctx.nessaUserId, status: input.status };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error("Failed to RSVP to Nessa event:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to RSVP to event"
});
}
}),
participants: nessaProcedure
.input(idSchema)
.query(async ({ input }) => {
try {
const conn = NessaConnectionFactory();
const result = await conn.execute({
sql: `SELECT r.id, r.eventId, r.userId, r.status, r.createdAt,
u.firstName, u.lastName, u.displayName, u.avatarUrl
FROM clubEventRSVPs r
JOIN users u ON r.userId = u.id
WHERE r.eventId = ?
ORDER BY r.createdAt ASC`,
args: [input.id]
});
return { participants: result.rows };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error("Failed to list event participants:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to list participants"
});
}
})
}) })
}); });

View File

@@ -5,7 +5,12 @@ import type { Row } from "@libsql/client/web";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { ConnectionFactory } from "./db-connections"; import { ConnectionFactory } from "./db-connections";
import { AUTH_CONFIG, LINEAGE_CONFIG, expiryToSeconds, getAccessTokenExpiry } from "~/config"; import {
AUTH_CONFIG,
LINEAGE_CONFIG,
expiryToSeconds,
getAccessTokenExpiry
} from "~/config";
export const authCookieName = "auth_token"; export const authCookieName = "auth_token";
@@ -65,7 +70,17 @@ export async function verifyAuthToken(
exp: payload.exp exp: payload.exp
}; };
} catch (error) { } catch (error) {
console.error("Auth token verification failed:", error); // Signature mismatch, expired token, malformed JWT — these are expected
// when a user has a stale/invalid cookie and are NOT server errors. Only
// log unexpected failures to keep prod console noise-free.
const code = (error as { code?: string }).code;
if (
code !== "ERR_JWS_SIGNATURE_VERIFICATION_FAILED" &&
code !== "ERR_JWT_EXPIRED" &&
code !== "ERR_JWT_MALFORMED"
) {
console.error("Auth token verification failed:", error);
}
return null; return null;
} }
} }