Day-of-launch deep-dive audit and remediation. 11 issues fixed across security, correctness, and reliability. Tests: 698 → 758 passing (+60), 0 failing, 2 skipped. CRITICAL (security) Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead useHelcim.js deleted. Production token MUST BE ROTATED post-deploy (was previously exposed in window.__NUXT__ payload). Fix #2 — /api/helcim/customer gated with origin check + per-IP/email rate limit + magic-link email verification (replaces unauthenticated setAuthCookie). Adds payment-bridge token for paid-tier signup so users can complete Helcim checkout before email verify. New utils: server/utils/{magicLink,rateLimit}.js. UX: signup success copy now prompts user to check email. Fix #3 — /api/events/[id]/payment deleted (dead code with unauth member-spoof bypass — processHelcimPayment was a permanent stub). Removes processHelcimPayment export and eventPaymentSchema. Fix #4 — /api/helcim/initialize-payment re-derives ticket amount server-side via calculateTicketPrice and calculateSeriesTicketPrice. Adds new series_ticket metadata type (was being shoved through event_ticket with seriesId in metadata.eventId). Fix #5 — /api/helcim/customer upgrades existing status:guest members in place rather than rejecting with 409. Lowercases email at lookup; preserves _id so prior event registrations stay linked. HIGH (correctness / reliability) Fix #6 — Daily reconciliation cron via Netlify scheduled function (@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs, server/api/internal/reconcile-payments.post.js. Shared-secret auth via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff on Helcim transactions API. Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist endpoints) to dodge legacy location validators. Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest Member when caller is unauthenticated, mirrors event-ticket flow byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and client auth refresh on signedIn:true response. Fix #9 — /api/members/cancel-subscription leaves status active per ratified bylaws (was pending_payment). Adds lastCancelledAt audit field on Member model. Indirectly fixes false-positive detectStuckPendingPayment admin alert for cancelled members. Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema (verifyMagicLinkSchema, max 2000 chars). Fix #11 — 8 vitest cases for cancel-subscription handler (was uncovered). Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md. LAUNCH_READINESS.md updated with new test count, 3 deploy-time tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify Netlify scheduled function), and Fixed-2026-04-25 fix log.
139 lines
3.6 KiB
JavaScript
139 lines
3.6 KiB
JavaScript
// server/models/member.js
|
|
import mongoose from "mongoose";
|
|
import { resolve } from "path";
|
|
import { fileURLToPath } from "url";
|
|
|
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
|
|
// Import configs using dynamic imports to avoid build issues
|
|
const getValidCircleValues = () => ["community", "founder", "practitioner"];
|
|
|
|
const memberSchema = new mongoose.Schema({
|
|
email: { type: String, required: true, unique: true },
|
|
emailHistory: [
|
|
{
|
|
email: { type: String, required: true },
|
|
changedAt: { type: Date, default: Date.now },
|
|
},
|
|
],
|
|
name: { type: String, required: true },
|
|
circle: {
|
|
type: String,
|
|
enum: getValidCircleValues(),
|
|
required: true,
|
|
},
|
|
contributionAmount: {
|
|
type: Number,
|
|
required: true,
|
|
min: 0,
|
|
validate: {
|
|
validator: Number.isInteger,
|
|
message: 'contributionAmount must be a whole number',
|
|
},
|
|
},
|
|
role: {
|
|
type: String,
|
|
enum: ["member", "admin"],
|
|
default: "member",
|
|
},
|
|
status: {
|
|
type: String,
|
|
enum: ["pending_payment", "active", "suspended", "cancelled", "guest"],
|
|
default: "pending_payment",
|
|
},
|
|
helcimCustomerId: String,
|
|
helcimSubscriptionId: String,
|
|
billingCadence: {
|
|
type: String,
|
|
enum: ['monthly', 'annual'],
|
|
default: 'monthly',
|
|
},
|
|
paymentMethod: {
|
|
type: String,
|
|
enum: ["card", "bank", "none"],
|
|
default: "none",
|
|
},
|
|
subscriptionStartDate: Date,
|
|
subscriptionEndDate: Date,
|
|
nextBillingDate: Date,
|
|
lastCancelledAt: Date,
|
|
slackInvited: { type: Boolean, default: false },
|
|
slackInviteStatus: {
|
|
type: String,
|
|
enum: ["pending", "sent", "failed", "accepted", "joined"],
|
|
default: "pending",
|
|
},
|
|
slackUserId: String,
|
|
|
|
// Profile fields
|
|
pronouns: String,
|
|
timeZone: String,
|
|
avatar: String,
|
|
studio: String,
|
|
bio: String,
|
|
location: String,
|
|
socialLinks: {
|
|
mastodon: String,
|
|
linkedin: String,
|
|
website: String,
|
|
other: String,
|
|
},
|
|
showInDirectory: { type: Boolean, default: true },
|
|
|
|
craftTags: [String],
|
|
board: {
|
|
slackHandle: String,
|
|
},
|
|
|
|
notifications: {
|
|
events: { type: Boolean, default: true },
|
|
},
|
|
|
|
inviteEmailSent: { type: Boolean, default: false },
|
|
inviteEmailSentAt: Date,
|
|
|
|
agreement: {
|
|
acceptedAt: Date,
|
|
},
|
|
|
|
taxReceiptPreferences: {
|
|
filesCanadianTaxes: { type: Boolean, default: false },
|
|
middleInitial: { type: String, default: null },
|
|
confirmedAddress: {
|
|
street: { type: String, default: null },
|
|
city: { type: String, default: null },
|
|
province: { type: String, default: null },
|
|
country: { type: String, default: null },
|
|
postalCode: { type: String, default: null },
|
|
},
|
|
setupCompletedAt: { type: Date, default: null },
|
|
},
|
|
|
|
// Magic link single-use enforcement
|
|
magicLinkJti: String,
|
|
magicLinkJtiUsed: { type: Boolean, default: false },
|
|
|
|
// Session revocation via token versioning
|
|
tokenVersion: { type: Number, default: 0 },
|
|
|
|
memberNumber: { type: Number, unique: true, sparse: true },
|
|
|
|
onboarding: {
|
|
completedAt: { type: Date, default: null },
|
|
eventPageVisited: { type: Boolean, default: false },
|
|
boardPageVisited: { type: Boolean, default: false },
|
|
wikiClicked: { type: Boolean, default: false },
|
|
skipped: {
|
|
profileTags: { type: Boolean, default: false },
|
|
visitEvent: { type: Boolean, default: false },
|
|
board: { type: Boolean, default: false },
|
|
wiki: { type: Boolean, default: false },
|
|
},
|
|
},
|
|
|
|
createdAt: { type: Date, default: Date.now },
|
|
lastLogin: Date,
|
|
});
|
|
|
|
// Check if model already exists to prevent re-compilation in development
|
|
export default mongoose.models.Member || mongoose.model("Member", memberSchema);
|