Add Zod validation, fix mass assignment, remove test endpoints and dead code
- Add centralized Zod schemas (server/utils/schemas.js) and validateBody utility for all API endpoints - Fix critical mass assignment in member creation: raw body no longer passed to new Member(), only validated fields (email, name, circle, contributionTier) are accepted - Apply Zod validation to login, profile patch, event registration, updates, verify-payment, and admin event creation endpoints - Fix logout cookie flags to match login (httpOnly: true, secure conditional on NODE_ENV) - Delete unauthenticated test/debug endpoints (test-connection, test-subscription, test-bot) - Remove sensitive console.log statements from Helcim and member endpoints - Remove unused bcryptjs dependency - Add 10MB file size limit on image uploads - Use runtime config for JWT secret across all endpoints - Add 38 validation tests (117 total, all passing)
This commit is contained in:
parent
26c300c357
commit
b7279f57d1
41 changed files with 467 additions and 321 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -17,10 +17,10 @@ logs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.fleet
|
.fleet
|
||||||
.idea
|
.idea
|
||||||
|
docs/*
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
/docs/
|
|
||||||
scripts/*.js
|
scripts/*.js
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
if (import.meta.server) return
|
if (process.server) return
|
||||||
|
|
||||||
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
|
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
|
||||||
|
|
||||||
|
|
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -14,7 +14,6 @@
|
||||||
"@nuxt/ui": "^4.0.0",
|
"@nuxt/ui": "^4.0.0",
|
||||||
"@nuxtjs/plausible": "^3.0.1",
|
"@nuxtjs/plausible": "^3.0.1",
|
||||||
"@slack/web-api": "^7.10.0",
|
"@slack/web-api": "^7.10.0",
|
||||||
"bcryptjs": "^3.0.2",
|
|
||||||
"chrono-node": "^2.8.4",
|
"chrono-node": "^2.8.4",
|
||||||
"cloudinary": "^2.7.0",
|
"cloudinary": "^2.7.0",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.34.0",
|
||||||
|
|
@ -7606,15 +7605,6 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bcryptjs": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"bin": {
|
|
||||||
"bcrypt": "bin/bcrypt"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bidi-js": {
|
"node_modules/bidi-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"@nuxt/ui": "^4.0.0",
|
"@nuxt/ui": "^4.0.0",
|
||||||
"@nuxtjs/plausible": "^3.0.1",
|
"@nuxtjs/plausible": "^3.0.1",
|
||||||
"@slack/web-api": "^7.10.0",
|
"@slack/web-api": "^7.10.0",
|
||||||
"bcryptjs": "^3.0.2",
|
|
||||||
"chrono-node": "^2.8.4",
|
"chrono-node": "^2.8.4",
|
||||||
"cloudinary": "^2.7.0",
|
"cloudinary": "^2.7.0",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.34.0",
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
import Event from "../../models/event.js";
|
import Event from "../../models/event.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
import { requireAdmin } from "../../utils/auth.js";
|
import { requireAdmin } from "../../utils/auth.js";
|
||||||
|
import { validateBody } from "../../utils/validateBody.js";
|
||||||
|
import { adminEventCreateSchema } from "../../utils/schemas.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const admin = await requireAdmin(event);
|
const admin = await requireAdmin(event);
|
||||||
|
|
||||||
const body = await readBody(event);
|
const body = await validateBody(event, adminEventCreateSchema);
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!body.title || !body.description || !body.startDate || !body.endDate) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: "Missing required fields",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import jwt from "jsonwebtoken";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import Member from "../../models/member.js";
|
import Member from "../../models/member.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
import { validateBody } from "../../utils/validateBody.js";
|
||||||
|
import { emailSchema } from "../../utils/schemas.js";
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
|
||||||
|
|
@ -10,14 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Connect to database
|
// Connect to database
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
const { email } = await readBody(event);
|
const { email } = await validateBody(event, emailSchema);
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: "Email is required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
|
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
|
||||||
|
|
||||||
|
|
@ -31,10 +26,11 @@ export default defineEventHandler(async (event) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate magic link token
|
// Generate magic link token (use runtime config for consistency with verify/requireAuth)
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ memberId: member._id },
|
{ memberId: member._id },
|
||||||
process.env.JWT_SECRET,
|
config.jwtSecret,
|
||||||
{ expiresIn: "15m" },
|
{ expiresIn: "15m" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
// Clear the auth token cookie
|
// Clear the auth token cookie (flags must match login for proper clearing)
|
||||||
setCookie(event, 'auth-token', '', {
|
setCookie(event, 'auth-token', '', {
|
||||||
httpOnly: false, // Match the original cookie settings
|
httpOnly: true,
|
||||||
secure: false, // Don't require HTTPS in development
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 0 // Expire immediately
|
maxAge: 0 // Expire immediately
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
|
||||||
const member = await Member.findById(decoded.memberId).select('-__v')
|
const member = await Member.findById(decoded.memberId).select('-__v')
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify the JWT token
|
// Verify the JWT token (use runtime config for consistency with login/requireAuth)
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
const config = useRuntimeConfig(event)
|
||||||
|
const decoded = jwt.verify(token, config.jwtSecret)
|
||||||
const member = await Member.findById(decoded.memberId)
|
const member = await Member.findById(decoded.memberId)
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
|
|
@ -32,7 +33,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Create a new session token for the authenticated user
|
// Create a new session token for the authenticated user
|
||||||
const sessionToken = jwt.sign(
|
const sessionToken = jwt.sign(
|
||||||
{ memberId: member._id, email: member.email },
|
{ memberId: member._id, email: member.email },
|
||||||
process.env.JWT_SECRET,
|
config.jwtSecret,
|
||||||
{ expiresIn: '7d' }
|
{ expiresIn: '7d' }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import Event from "../../../models/event.js";
|
||||||
import Member from "../../../models/member.js";
|
import Member from "../../../models/member.js";
|
||||||
import { connectDB } from "../../../utils/mongoose.js";
|
import { connectDB } from "../../../utils/mongoose.js";
|
||||||
import { sendEventRegistrationEmail } from "../../../utils/resend.js";
|
import { sendEventRegistrationEmail } from "../../../utils/resend.js";
|
||||||
|
import { validateBody } from "../../../utils/validateBody.js";
|
||||||
|
import { eventRegistrationSchema } from "../../../utils/schemas.js";
|
||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -9,7 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Ensure database connection
|
// Ensure database connection
|
||||||
await connectDB();
|
await connectDB();
|
||||||
const identifier = getRouterParam(event, "id");
|
const identifier = getRouterParam(event, "id");
|
||||||
const body = await readBody(event);
|
const body = await validateBody(event, eventRegistrationSchema);
|
||||||
|
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
@ -18,14 +20,6 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!body.name || !body.email) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: "Name and email are required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the event - try by slug first, then by ID
|
// Fetch the event - try by slug first, then by ID
|
||||||
let eventData;
|
let eventData;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,6 @@ export default defineEventHandler(async (event) => {
|
||||||
// Optional: Verify the transaction with Helcim API
|
// Optional: Verify the transaction with Helcim API
|
||||||
// This adds extra security to ensure the transaction is legitimate
|
// This adds extra security to ensure the transaction is legitimate
|
||||||
// For now, we trust the transaction ID from HelcimPay.js
|
// For now, we trust the transaction ID from HelcimPay.js
|
||||||
console.log("Payment completed with transaction ID:", transactionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create registration
|
// Create registration
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
||||||
|
|
||||||
console.log('Creating payment plan:', body.name)
|
|
||||||
|
|
||||||
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
|
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -44,7 +43,6 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const planData = await response.json()
|
const planData = await response.json()
|
||||||
console.log('Payment plan created:', planData)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Decode JWT token
|
// Decode JWT token
|
||||||
let decoded
|
let decoded
|
||||||
try {
|
try {
|
||||||
decoded = jwt.verify(token, process.env.JWT_SECRET)
|
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export default defineEventHandler(async (event) => {
|
||||||
email: body.email,
|
email: body.email,
|
||||||
helcimCustomerId: customerData.id
|
helcimCustomerId: customerData.id
|
||||||
},
|
},
|
||||||
process.env.JWT_SECRET,
|
config.jwtSecret,
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Decode JWT token
|
// Decode JWT token
|
||||||
let decoded
|
let decoded
|
||||||
try {
|
try {
|
||||||
decoded = jwt.verify(token, process.env.JWT_SECRET)
|
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
@ -59,7 +59,6 @@ export default defineEventHandler(async (event) => {
|
||||||
const existingCustomer = searchData.customers.find(c => c.email === member.email)
|
const existingCustomer = searchData.customers.find(c => c.email === member.email)
|
||||||
|
|
||||||
if (existingCustomer) {
|
if (existingCustomer) {
|
||||||
console.log('Found existing Helcim customer:', existingCustomer.id)
|
|
||||||
|
|
||||||
// Update member record with customer ID if not already set
|
// Update member record with customer ID if not already set
|
||||||
if (!member.helcimCustomerId) {
|
if (!member.helcimCustomerId) {
|
||||||
|
|
@ -77,12 +76,11 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (searchError) {
|
} catch (searchError) {
|
||||||
console.log('Error searching for customer:', searchError)
|
console.error('Error searching for customer:', searchError)
|
||||||
// Continue to create new customer
|
// Continue to create new customer
|
||||||
}
|
}
|
||||||
|
|
||||||
// No existing customer found, create new one
|
// No existing customer found, create new one
|
||||||
console.log('Creating new Helcim customer for:', member.email)
|
|
||||||
const createResponse = await fetch(`${HELCIM_API_BASE}/customers`, {
|
const createResponse = await fetch(`${HELCIM_API_BASE}/customers`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -107,7 +105,6 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerData = await createResponse.json()
|
const customerData = await createResponse.json()
|
||||||
console.log('Created Helcim customer:', customerData.id)
|
|
||||||
|
|
||||||
// Update member record with customer ID
|
// Update member record with customer ID
|
||||||
member.helcimCustomerId = customerData.id
|
member.helcimCustomerId = customerData.id
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
||||||
|
|
||||||
console.log('Fetching payment plans from Helcim...')
|
|
||||||
|
|
||||||
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
|
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -18,17 +16,13 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch payment plans:', response.status, response.statusText)
|
console.error('Failed to fetch payment plans:', response.status, response.statusText)
|
||||||
const errorText = await response.text()
|
|
||||||
console.error('Response body:', errorText)
|
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: response.status,
|
statusCode: response.status,
|
||||||
statusMessage: `Failed to fetch payment plans: ${errorText}`
|
statusMessage: 'Failed to fetch payment plans'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const plansData = await response.json()
|
const plansData = await response.json()
|
||||||
console.log('Payment plans retrieved:', JSON.stringify(plansData, null, 2))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
||||||
|
|
||||||
console.log('Fetching existing subscriptions from Helcim...')
|
|
||||||
|
|
||||||
const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
|
const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -18,17 +16,13 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch subscriptions:', response.status, response.statusText)
|
console.error('Failed to fetch subscriptions:', response.status, response.statusText)
|
||||||
const errorText = await response.text()
|
|
||||||
console.error('Response body:', errorText)
|
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: response.status,
|
statusCode: response.status,
|
||||||
statusMessage: `Failed to fetch subscriptions: ${errorText}`
|
statusMessage: 'Failed to fetch subscriptions'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionsData = await response.json()
|
const subscriptionsData = await response.json()
|
||||||
console.log('Existing subscriptions:', JSON.stringify(subscriptionsData, null, 2))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
// Test Helcim API connection
|
|
||||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
try {
|
|
||||||
const config = useRuntimeConfig(event)
|
|
||||||
|
|
||||||
// Log token info (safely)
|
|
||||||
const tokenInfo = {
|
|
||||||
hasToken: !!config.public.helcimToken,
|
|
||||||
tokenLength: config.public.helcimToken ? config.public.helcimToken.length : 0,
|
|
||||||
tokenPrefix: config.public.helcimToken ? config.public.helcimToken.substring(0, 10) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Helcim Token Info:', tokenInfo)
|
|
||||||
|
|
||||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
|
||||||
|
|
||||||
// Try connection test endpoint
|
|
||||||
const response = await $fetch(`${HELCIM_API_BASE}/connection-test`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'api-token': helcimToken
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Helcim API connection successful',
|
|
||||||
tokenInfo,
|
|
||||||
connectionResponse: response
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Helcim test error:', error)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Failed to connect to Helcim API',
|
|
||||||
statusCode: error.statusCode,
|
|
||||||
tokenInfo: {
|
|
||||||
hasToken: !!useRuntimeConfig().public.helcimToken,
|
|
||||||
tokenLength: useRuntimeConfig().public.helcimToken ? useRuntimeConfig().public.helcimToken.length : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
// Test minimal subscription creation to understand required fields
|
|
||||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
try {
|
|
||||||
const config = useRuntimeConfig(event)
|
|
||||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
|
||||||
|
|
||||||
// Generate a 25-character idempotency key
|
|
||||||
const idempotencyKey = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`.substring(0, 25)
|
|
||||||
|
|
||||||
// Test with minimal fields first
|
|
||||||
const testRequest1 = {
|
|
||||||
customerCode: 'CST1020', // Use a recent customer code
|
|
||||||
planId: 20162
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Testing subscription with minimal fields:', testRequest1)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response1 = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'content-type': 'application/json',
|
|
||||||
'api-token': helcimToken,
|
|
||||||
'idempotency-key': idempotencyKey + 'a'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(testRequest1)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result1 = await response1.text()
|
|
||||||
console.log('Test 1 - Status:', response1.status)
|
|
||||||
console.log('Test 1 - Response:', result1)
|
|
||||||
|
|
||||||
if (!response1.ok) {
|
|
||||||
// Try with paymentPlanId instead
|
|
||||||
const testRequest2 = {
|
|
||||||
customerCode: 'CST1020',
|
|
||||||
paymentPlanId: 20162
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Testing subscription with paymentPlanId:', testRequest2)
|
|
||||||
|
|
||||||
const response2 = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'content-type': 'application/json',
|
|
||||||
'api-token': helcimToken,
|
|
||||||
'idempotency-key': idempotencyKey + 'b'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(testRequest2)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result2 = await response2.text()
|
|
||||||
console.log('Test 2 - Status:', response2.status)
|
|
||||||
console.log('Test 2 - Response:', result2)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Test error:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Check server logs for test results'
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in test endpoint:', error)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: error.message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
// Verify payment token from HelcimPay.js
|
// Verify payment token from HelcimPay.js
|
||||||
import { requireAuth } from '../../utils/auth.js'
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
|
import { paymentVerifySchema } from '../../utils/schemas.js'
|
||||||
|
|
||||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
||||||
|
|
||||||
|
|
@ -7,15 +9,7 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
await requireAuth(event)
|
await requireAuth(event)
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
const body = await readBody(event)
|
const body = await validateBody(event, paymentVerifySchema)
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!body.cardToken || !body.customerId) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Card token and customer ID are required'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
||||||
|
|
||||||
|
|
@ -48,13 +42,13 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
// Verify the card token exists for this customer
|
// Verify the card token exists for this customer
|
||||||
const cardExists = Array.isArray(cards) && cards.some(card =>
|
const cardExists = Array.isArray(cards) && cards.some(card =>
|
||||||
card.cardToken === body.cardToken || card.id
|
card.cardToken === body.cardToken
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!cardExists && Array.isArray(cards) && cards.length === 0) {
|
if (!cardExists) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: 'No payment method found for this customer'
|
statusMessage: 'Payment method not found or does not belong to this customer'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Decode JWT token
|
// Decode JWT token
|
||||||
let decoded;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
decoded = jwt.verify(token, config.jwtSecret);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import { getSlackService } from '../../utils/slack.ts'
|
import { getSlackService } from '../../utils/slack.ts'
|
||||||
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
|
import { memberCreateSchema } from '../../utils/schemas.js'
|
||||||
// Simple payment check function to avoid import issues
|
// Simple payment check function to avoid import issues
|
||||||
const requiresPayment = (contributionValue) => contributionValue !== '0'
|
const requiresPayment = (contributionValue) => contributionValue !== '0'
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ async function inviteToSlack(member) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Processing Slack invitation for ${member.email}...`)
|
console.warn(`Processing Slack invitation for member`)
|
||||||
|
|
||||||
const inviteResult = await slackService.inviteUserToSlack(
|
const inviteResult = await slackService.inviteUserToSlack(
|
||||||
member.email,
|
member.email,
|
||||||
|
|
@ -45,13 +47,13 @@ async function inviteToSlack(member) {
|
||||||
inviteResult.status
|
inviteResult.status
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`)
|
console.warn(`Slack invitation processed: ${inviteResult.status}`)
|
||||||
} else {
|
} else {
|
||||||
// Update member record to reflect failed invitation
|
// Update member record to reflect failed invitation
|
||||||
member.slackInviteStatus = 'failed'
|
member.slackInviteStatus = 'failed'
|
||||||
await member.save()
|
await member.save()
|
||||||
|
|
||||||
console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`)
|
console.error(`Failed to process Slack invitation: ${inviteResult.error}`)
|
||||||
// Don't throw error - member creation should still succeed
|
// Don't throw error - member creation should still succeed
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -73,11 +75,11 @@ export default defineEventHandler(async (event) => {
|
||||||
// Ensure database is connected
|
// Ensure database is connected
|
||||||
await connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const body = await readBody(event)
|
const validatedData = await validateBody(event, memberCreateSchema)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if member already exists
|
// Check if member already exists
|
||||||
const existingMember = await Member.findOne({ email: body.email })
|
const existingMember = await Member.findOne({ email: validatedData.email })
|
||||||
if (existingMember) {
|
if (existingMember) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
|
|
@ -85,20 +87,18 @@ export default defineEventHandler(async (event) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = new Member(body)
|
const member = new Member(validatedData)
|
||||||
await member.save()
|
await member.save()
|
||||||
|
|
||||||
// Send Slack invitation for new members
|
// Send Slack invitation for new members
|
||||||
await inviteToSlack(member)
|
await inviteToSlack(member)
|
||||||
|
|
||||||
// TODO: Process payment with Helcim if not free tier
|
// TODO: Process payment with Helcim if not free tier
|
||||||
if (requiresPayment(body.contributionTier)) {
|
if (requiresPayment(validatedData.contributionTier)) {
|
||||||
// Payment processing will be added here
|
// Payment processing will be added here
|
||||||
console.log('Payment processing needed for tier:', body.contributionTier)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Send welcome email
|
// TODO: Send welcome email
|
||||||
console.log('Welcome email should be sent to:', body.email)
|
|
||||||
|
|
||||||
return { success: true, member }
|
return { success: true, member }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
currentMemberId = decoded.memberId;
|
currentMemberId = decoded.memberId;
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import Member from "../../models/member.js";
|
import Member from "../../models/member.js";
|
||||||
import { requireAuth } from "../../utils/auth.js";
|
import { requireAuth } from "../../utils/auth.js";
|
||||||
|
import { validateBody } from "../../utils/validateBody.js";
|
||||||
|
import { memberProfileUpdateSchema } from "../../utils/schemas.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const authedMember = await requireAuth(event);
|
const authedMember = await requireAuth(event);
|
||||||
const memberId = authedMember._id;
|
const memberId = authedMember._id;
|
||||||
|
|
||||||
const body = await readBody(event);
|
const body = await validateBody(event, memberProfileUpdateSchema);
|
||||||
|
|
||||||
// Define allowed profile fields
|
// Profile fields from validated body
|
||||||
const allowedFields = [
|
const profileFields = [
|
||||||
"pronouns",
|
"pronouns",
|
||||||
"timeZone",
|
"timeZone",
|
||||||
"avatar",
|
"avatar",
|
||||||
|
|
@ -19,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
||||||
"showInDirectory",
|
"showInDirectory",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Define privacy fields
|
// Privacy fields from validated body
|
||||||
const privacyFields = [
|
const privacyFields = [
|
||||||
"pronounsPrivacy",
|
"pronounsPrivacy",
|
||||||
"timeZonePrivacy",
|
"timeZonePrivacy",
|
||||||
|
|
@ -32,10 +34,10 @@ export default defineEventHandler(async (event) => {
|
||||||
"lookingForPrivacy",
|
"lookingForPrivacy",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build update object
|
// Build update object from validated data
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
|
|
||||||
allowedFields.forEach((field) => {
|
profileFields.forEach((field) => {
|
||||||
if (body[field] !== undefined) {
|
if (body[field] !== undefined) {
|
||||||
updateData[field] = body[field];
|
updateData[field] = body[field];
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +75,7 @@ export default defineEventHandler(async (event) => {
|
||||||
if (!member) {
|
if (!member) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
message: "Member not found",
|
statusMessage: "Member not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +101,7 @@ export default defineEventHandler(async (event) => {
|
||||||
console.error("Profile update error:", error);
|
console.error("Profile update error:", error);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
message: "Failed to update profile",
|
statusMessage: "Failed to update profile",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Decode JWT token
|
// Decode JWT token
|
||||||
let decoded;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
jwt.verify(token, process.env.JWT_SECRET);
|
jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
isAuthenticated = false;
|
isAuthenticated = false;
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { WebClient } from '@slack/web-api'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
if (!config.slackBotToken) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Slack bot token not configured'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new WebClient(config.slackBotToken)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test basic API access
|
|
||||||
const authTest = await client.auth.test()
|
|
||||||
console.log('Auth test result:', authTest)
|
|
||||||
|
|
||||||
// Test if admin API is available
|
|
||||||
let adminApiAvailable = false
|
|
||||||
let adminError = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to call admin.users.list to test admin API access
|
|
||||||
await client.admin.users.list({ limit: 1 })
|
|
||||||
adminApiAvailable = true
|
|
||||||
} catch (error: any) {
|
|
||||||
adminError = error.data?.error || error.message
|
|
||||||
console.log('Admin API test failed:', adminError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test channel access if channel ID is configured
|
|
||||||
let channelAccess = false
|
|
||||||
let channelError = null
|
|
||||||
|
|
||||||
if (config.slackVettingChannelId) {
|
|
||||||
try {
|
|
||||||
const channelInfo = await client.conversations.info({
|
|
||||||
channel: config.slackVettingChannelId
|
|
||||||
})
|
|
||||||
channelAccess = !!channelInfo.channel
|
|
||||||
} catch (error: any) {
|
|
||||||
channelError = error.data?.error || error.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
botInfo: {
|
|
||||||
user: authTest.user,
|
|
||||||
team: authTest.team,
|
|
||||||
url: authTest.url
|
|
||||||
},
|
|
||||||
adminApiAvailable,
|
|
||||||
adminError: adminApiAvailable ? null : adminError,
|
|
||||||
channelAccess,
|
|
||||||
channelError: channelAccess ? null : channelError,
|
|
||||||
channelId: config.slackVettingChannelId || 'Not configured'
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.data?.error || error.message || 'Unknown error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({ statusCode: 401, statusMessage: "Invalid token" });
|
throw createError({ statusCode: 401, statusMessage: "Invalid token" });
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, continue as non-member
|
// Token invalid, continue as non-member
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, continue as non-member
|
// Token invalid, continue as non-member
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import Update from "../../models/update.js";
|
import Update from "../../models/update.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
import { validateBody } from "../../utils/validateBody.js";
|
||||||
|
import { updateCreateSchema } from "../../utils/schemas.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
@ -16,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
@ -25,14 +27,7 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody(event);
|
const body = await validateBody(event, updateCreateSchema);
|
||||||
|
|
||||||
if (!body.content || !body.content.trim()) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: "Content is required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const update = await Update.create({
|
const update = await Update.create({
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||||
currentMemberId = decoded.memberId;
|
currentMemberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, continue as non-member
|
// Token invalid, continue as non-member
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,15 @@ export default defineEventHandler(async (event) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate file size (10MB limit)
|
||||||
|
const maxSize = 10 * 1024 * 1024
|
||||||
|
if (fileData.data.length > maxSize) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'File too large. Maximum size is 10MB.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Convert buffer to base64 for Cloudinary upload
|
// Convert buffer to base64 for Cloudinary upload
|
||||||
const base64File = `data:${fileData.type};base64,${fileData.data.toString('base64')}`
|
const base64File = `data:${fileData.type};base64,${fileData.data.toString('base64')}`
|
||||||
|
|
||||||
|
|
|
||||||
96
server/utils/schemas.js
Normal file
96
server/utils/schemas.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import * as z from 'zod'
|
||||||
|
|
||||||
|
const privacyEnum = z.enum(['public', 'members', 'private'])
|
||||||
|
|
||||||
|
export const emailSchema = z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const memberCreateSchema = z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email(),
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
circle: z.enum(['community', 'founder', 'practitioner']),
|
||||||
|
contributionTier: z.enum(['0', '5', '15', '30', '50'])
|
||||||
|
})
|
||||||
|
|
||||||
|
export const memberProfileUpdateSchema = z.object({
|
||||||
|
pronouns: z.string().max(100).optional(),
|
||||||
|
timeZone: z.string().max(100).optional(),
|
||||||
|
avatar: z.union([z.string().url().max(500), z.literal('')]).optional(),
|
||||||
|
studio: z.string().max(200).optional(),
|
||||||
|
bio: z.string().max(5000).optional(),
|
||||||
|
location: z.string().max(200).optional(),
|
||||||
|
socialLinks: z.object({
|
||||||
|
mastodon: z.string().max(300).optional(),
|
||||||
|
linkedin: z.string().max(300).optional(),
|
||||||
|
website: z.string().max(300).optional(),
|
||||||
|
other: z.string().max(300).optional()
|
||||||
|
}).optional(),
|
||||||
|
offering: z.object({
|
||||||
|
text: z.string().max(2000).optional(),
|
||||||
|
tags: z.array(z.string().max(100)).max(20).optional()
|
||||||
|
}).optional(),
|
||||||
|
lookingFor: z.object({
|
||||||
|
text: z.string().max(2000).optional(),
|
||||||
|
tags: z.array(z.string().max(100)).max(20).optional()
|
||||||
|
}).optional(),
|
||||||
|
showInDirectory: z.boolean().optional(),
|
||||||
|
pronounsPrivacy: privacyEnum.optional(),
|
||||||
|
timeZonePrivacy: privacyEnum.optional(),
|
||||||
|
avatarPrivacy: privacyEnum.optional(),
|
||||||
|
studioPrivacy: privacyEnum.optional(),
|
||||||
|
bioPrivacy: privacyEnum.optional(),
|
||||||
|
locationPrivacy: privacyEnum.optional(),
|
||||||
|
socialLinksPrivacy: privacyEnum.optional(),
|
||||||
|
offeringPrivacy: privacyEnum.optional(),
|
||||||
|
lookingForPrivacy: privacyEnum.optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const eventRegistrationSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
email: z.string().trim().toLowerCase().email(),
|
||||||
|
dietary: z.boolean().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateCreateSchema = z.object({
|
||||||
|
content: z.string().min(1).max(50000),
|
||||||
|
images: z.array(z.string().url()).max(20).optional(),
|
||||||
|
privacy: z.enum(['public', 'members', 'private']).optional(),
|
||||||
|
commentsEnabled: z.boolean().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const paymentVerifySchema = z.object({
|
||||||
|
cardToken: z.string().min(1),
|
||||||
|
customerId: z.string().min(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const adminEventCreateSchema = z.object({
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
description: z.string().min(1).max(50000),
|
||||||
|
startDate: z.string().min(1),
|
||||||
|
endDate: z.string().min(1),
|
||||||
|
location: z.string().max(500).optional(),
|
||||||
|
maxAttendees: z.number().int().positive().optional(),
|
||||||
|
membersOnly: z.boolean().optional(),
|
||||||
|
registrationDeadline: z.string().optional(),
|
||||||
|
pricing: z.object({
|
||||||
|
paymentRequired: z.boolean().optional(),
|
||||||
|
isFree: z.boolean().optional()
|
||||||
|
}).optional(),
|
||||||
|
tickets: z.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
public: z.object({
|
||||||
|
available: z.boolean().optional(),
|
||||||
|
name: z.string().max(200).optional(),
|
||||||
|
description: z.string().max(2000).optional(),
|
||||||
|
price: z.number().min(0).optional(),
|
||||||
|
quantity: z.number().int().positive().optional(),
|
||||||
|
earlyBirdPrice: z.number().min(0).optional(),
|
||||||
|
earlyBirdDeadline: z.string().optional()
|
||||||
|
}).optional()
|
||||||
|
}).optional(),
|
||||||
|
image: z.string().url().optional(),
|
||||||
|
category: z.string().max(100).optional(),
|
||||||
|
tags: z.array(z.string().max(100)).max(20).optional(),
|
||||||
|
series: z.string().optional()
|
||||||
|
})
|
||||||
12
server/utils/validateBody.js
Normal file
12
server/utils/validateBody.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export async function validateBody(event, schema) {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const result = schema.safeParse(body)
|
||||||
|
if (!result.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Validation failed',
|
||||||
|
data: result.error.flatten().fieldErrors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
@ -107,7 +107,7 @@ describe('auth login endpoint', () => {
|
||||||
|
|
||||||
await expect(loginHandler(event)).rejects.toMatchObject({
|
await expect(loginHandler(event)).rejects.toMatchObject({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: 'Email is required'
|
statusMessage: 'Validation failed'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
273
tests/server/api/validation.test.js
Normal file
273
tests/server/api/validation.test.js
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
import {
|
||||||
|
emailSchema,
|
||||||
|
memberCreateSchema,
|
||||||
|
memberProfileUpdateSchema,
|
||||||
|
eventRegistrationSchema,
|
||||||
|
updateCreateSchema,
|
||||||
|
paymentVerifySchema,
|
||||||
|
adminEventCreateSchema
|
||||||
|
} from '../../../server/utils/schemas.js'
|
||||||
|
import { validateBody } from '../../../server/utils/validateBody.js'
|
||||||
|
|
||||||
|
// --- Schema unit tests ---
|
||||||
|
|
||||||
|
describe('emailSchema', () => {
|
||||||
|
it('accepts a valid email', () => {
|
||||||
|
const result = emailSchema.safeParse({ email: 'test@example.com' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data.email).toBe('test@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a malformed email', () => {
|
||||||
|
const result = emailSchema.safeParse({ email: 'not-an-email' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing email', () => {
|
||||||
|
const result = emailSchema.safeParse({})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims and lowercases email', () => {
|
||||||
|
const result = emailSchema.safeParse({ email: ' Test@EXAMPLE.COM ' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data.email).toBe('test@example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('memberCreateSchema', () => {
|
||||||
|
const validMember = {
|
||||||
|
email: 'new@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
circle: 'community',
|
||||||
|
contributionTier: '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid member data', () => {
|
||||||
|
const result = memberCreateSchema.safeParse(validMember)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects role field (mass assignment)', () => {
|
||||||
|
const result = memberCreateSchema.safeParse({ ...validMember, role: 'admin' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
// role should NOT be in the output
|
||||||
|
expect(result.data).not.toHaveProperty('role')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects status field (mass assignment)', () => {
|
||||||
|
const result = memberCreateSchema.safeParse({ ...validMember, status: 'active' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).not.toHaveProperty('status')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects helcimCustomerId field (mass assignment)', () => {
|
||||||
|
const result = memberCreateSchema.safeParse({ ...validMember, helcimCustomerId: 'cust_123' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).not.toHaveProperty('helcimCustomerId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects _id field (mass assignment)', () => {
|
||||||
|
const result = memberCreateSchema.safeParse({ ...validMember, _id: '507f1f77bcf86cd799439011' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).not.toHaveProperty('_id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid circle enum', () => {
|
||||||
|
const result = memberCreateSchema.safeParse({ ...validMember, circle: 'superadmin' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid contributionTier enum', () => {
|
||||||
|
const result = memberCreateSchema.safeParse({ ...validMember, contributionTier: '999' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing required fields', () => {
|
||||||
|
const result = memberCreateSchema.safeParse({ email: 'test@example.com' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lowercases email', () => {
|
||||||
|
const result = memberCreateSchema.safeParse({ ...validMember, email: 'NEW@Example.COM' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data.email).toBe('new@example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('eventRegistrationSchema', () => {
|
||||||
|
it('accepts valid registration', () => {
|
||||||
|
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'jane@example.com' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing name', () => {
|
||||||
|
const result = eventRegistrationSchema.safeParse({ email: 'jane@example.com' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed email', () => {
|
||||||
|
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'bad' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lowercases email', () => {
|
||||||
|
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'JANE@Example.COM' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data.email).toBe('jane@example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateCreateSchema', () => {
|
||||||
|
it('accepts valid content', () => {
|
||||||
|
const result = updateCreateSchema.safeParse({ content: 'Hello world' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty content', () => {
|
||||||
|
const result = updateCreateSchema.safeParse({ content: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects content exceeding 50000 chars', () => {
|
||||||
|
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50001) })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts content at exactly 50000 chars', () => {
|
||||||
|
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50000) })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates images are URLs', () => {
|
||||||
|
const result = updateCreateSchema.safeParse({
|
||||||
|
content: 'test',
|
||||||
|
images: ['not-a-url']
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts valid images array', () => {
|
||||||
|
const result = updateCreateSchema.safeParse({
|
||||||
|
content: 'test',
|
||||||
|
images: ['https://example.com/img.png']
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects more than 20 images', () => {
|
||||||
|
const images = Array.from({ length: 21 }, (_, i) => `https://example.com/img${i}.png`)
|
||||||
|
const result = updateCreateSchema.safeParse({ content: 'test', images })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates privacy enum', () => {
|
||||||
|
const result = updateCreateSchema.safeParse({ content: 'test', privacy: 'invalid' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('paymentVerifySchema', () => {
|
||||||
|
it('accepts valid card token and customer ID', () => {
|
||||||
|
const result = paymentVerifySchema.safeParse({ cardToken: 'tok_123', customerId: 'cust_456' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing cardToken', () => {
|
||||||
|
const result = paymentVerifySchema.safeParse({ customerId: 'cust_456' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty cardToken', () => {
|
||||||
|
const result = paymentVerifySchema.safeParse({ cardToken: '', customerId: 'cust_456' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('adminEventCreateSchema', () => {
|
||||||
|
const validEvent = {
|
||||||
|
title: 'Test Event',
|
||||||
|
description: 'A test event',
|
||||||
|
startDate: '2026-04-01T10:00:00Z',
|
||||||
|
endDate: '2026-04-01T12:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid event data', () => {
|
||||||
|
const result = adminEventCreateSchema.safeParse(validEvent)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing title', () => {
|
||||||
|
const { title, ...rest } = validEvent
|
||||||
|
const result = adminEventCreateSchema.safeParse(rest)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing dates', () => {
|
||||||
|
const { startDate, endDate, ...rest } = validEvent
|
||||||
|
const result = adminEventCreateSchema.safeParse({ ...rest, title: 'Test' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('memberProfileUpdateSchema', () => {
|
||||||
|
it('rejects role in profile update', () => {
|
||||||
|
const result = memberProfileUpdateSchema.safeParse({ role: 'admin', bio: 'test' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).not.toHaveProperty('role')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects status in profile update', () => {
|
||||||
|
const result = memberProfileUpdateSchema.safeParse({ status: 'active', bio: 'test' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).not.toHaveProperty('status')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates privacy enum values', () => {
|
||||||
|
const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'invalid' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts valid privacy values', () => {
|
||||||
|
const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'public' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- validateBody integration tests ---
|
||||||
|
|
||||||
|
describe('validateBody', () => {
|
||||||
|
it('returns validated data on success', async () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
body: { email: 'test@example.com' }
|
||||||
|
})
|
||||||
|
const data = await validateBody(event, emailSchema)
|
||||||
|
expect(data.email).toBe('test@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws 400 on validation failure', async () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
body: { email: 'bad' }
|
||||||
|
})
|
||||||
|
await expect(validateBody(event, emailSchema)).rejects.toMatchObject({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Validation failed'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips unknown fields from output', async () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
body: { email: 'test@example.com', name: 'Test', circle: 'community', contributionTier: '0', role: 'admin', _id: 'fake' }
|
||||||
|
})
|
||||||
|
const data = await validateBody(event, memberCreateSchema)
|
||||||
|
expect(data).not.toHaveProperty('role')
|
||||||
|
expect(data).not.toHaveProperty('_id')
|
||||||
|
expect(data.email).toBe('test@example.com')
|
||||||
|
expect(data.name).toBe('Test')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue