import { defineSchema, defineTable } from 'convex/server'; import { v } from 'convex/values'; export default defineSchema({ users: defineTable({ email: v.string(), name: v.optional(v.string()), lastName: v.optional(v.string()), firstName: v.optional(v.string()), phone: v.optional(v.string()), civilite: v.optional( v.union( v.literal('M.'), v.literal('Mme'), v.literal('Dr'), v.literal('Pr'), v.literal('Autre'), v.literal('Non-divulgué') ) ), role: v.optional(v.string()), banned: v.optional(v.boolean()), banReason: v.optional(v.string()), banExpires: v.optional(v.number()), imageUrl: v.optional(v.string()), imageId: v.optional(v.id('_storage')), }).index('email', ['email']), userProfiles: defineTable({ userId: v.id('users'), // Academic Information - simplified courseOfStudy: v.optional( v.union(v.literal('Médecine'), v.literal('Dentaire'), v.literal('Autre')) ), userType: v.union(v.literal('student'), v.literal('doctor')), country: v.optional(v.string()), university: v.optional(v.string()), yearOfStudy: v.optional(v.number()), studyLanguage: v.optional(v.string()), specialty: v.optional(v.string()), // Professional Information professionalStatus: v.optional( v.union( v.literal('doing_specialty'), v.literal('already_specialist'), v.literal('no_specialty') ) ), yearOfSpecialty: v.optional(v.number()), workPlace: v.optional( v.union( v.literal('hospital'), v.literal('cabinet'), v.literal('clinic'), v.literal('other') ) ), hospitalName: v.optional(v.string()), // EMC Information (for EMC package holders) emcCode: v.optional(v.string()), // Food Preferences (profile image moved to users table) foodPreference: v.optional( v.union( v.literal('normal'), v.literal('vegetarian'), v.literal('no_pork'), v.literal('other') ) ), foodAllergies: v.optional(v.array(v.string())), // Array of literal allergies and/or custom strings createdAt: v.number(), updatedAt: v.number(), }).index('userId', ['userId']), notificationSettings: defineTable({ userId: v.id('users'), // Notification channels emailNotifications: v.boolean(), smsNotifications: v.boolean(), // Event types activityStarting: v.boolean(), activityApproaching: v.boolean(), activityRegistrationOpen: v.boolean(), orderConfirmation: v.boolean(), certificateReady: v.boolean(), supportTicketUpdate: v.boolean(), generalAnnouncements: v.boolean(), createdAt: v.number(), updatedAt: v.number(), }).index('userId', ['userId']), adminTeams: defineTable({ name: v.string(), description: v.string(), type: v.union( v.literal('meals'), v.literal('activities'), v.literal('support'), v.literal('logistics'), v.literal('registration'), v.literal('sponsorship'), v.literal('other') ), isActive: v.boolean(), createdAt: v.number(), updatedAt: v.number(), }).index('name', ['name']), adminTeamMembers: defineTable({ teamId: v.id('adminTeams'), userId: v.id('users'), role: v.union(v.literal('lead'), v.literal('member')), joinedAt: v.number(), }) .index('teamId', ['teamId']) .index('userId', ['userId']) .index('teamUser', ['teamId', 'userId']), activities: defineTable({ name: v.string(), description: v.string(), type: v.union(v.literal('workshop'), v.literal('conference')), // Requirements minimumLevel: v.optional(v.number()), requiredCourses: v.optional(v.array(v.string())), prerequisites: v.optional(v.string()), // Schedule startDateTime: v.number(), endDateTime: v.number(), // Location locationName: v.string(), locationAddress: v.optional(v.string()), locationLatitude: v.optional(v.number()), locationLongitude: v.optional(v.number()), // People managingAdmins: v.array(v.id('users')), animators: v.array( v.object({ name: v.string(), role: v.string(), bio: v.optional(v.string()), imageUrl: v.optional(v.string()), }) ), // Capacity maxParticipants: v.number(), currentParticipants: v.number(), // Additional additionalNotes: v.optional(v.string()), isActive: v.boolean(), isFeatured: v.boolean(), createdAt: v.number(), updatedAt: v.number(), }) .index('type', ['type']) .index('startDateTime', ['startDateTime']) .index('isActive', ['isActive']), activityRegistrations: defineTable({ activityId: v.id('activities'), userId: v.id('users'), status: v.union( v.literal('registered'), v.literal('waitlisted'), v.literal('attended'), v.literal('cancelled'), v.literal('no_show') ), registeredAt: v.number(), attendedAt: v.optional(v.number()), cancelledAt: v.optional(v.number()), }) .index('activityId', ['activityId']) .index('userId', ['userId']) .index('activityUser', ['activityId', 'userId']), certificates: defineTable({ userId: v.id('users'), name: v.string(), type: v.union( v.literal('attendance'), v.literal('completion'), v.literal('emc'), v.literal('workshop'), v.literal('other') ), fileId: v.optional(v.id('_storage')), fileUrl: v.optional(v.string()), // Print status for physical certificates printStatus: v.union( v.literal('unavailable'), v.literal('requested'), v.literal('printing'), v.literal('ready'), v.literal('collected') ), printRequestedAt: v.optional(v.number()), printReadyAt: v.optional(v.number()), collectedAt: v.optional(v.number()), issuedAt: v.number(), expiresAt: v.optional(v.number()), }) .index('userId', ['userId']) .index('type', ['type']), meals: defineTable({ userId: v.id('users'), mealType: v.union( v.literal('breakfast'), v.literal('lunch'), v.literal('dinner'), v.literal('coffee_break'), v.literal('cocktail') ), date: v.number(), venue: v.string(), scannedAt: v.number(), scannedBy: v.optional(v.id('users')), }) .index('userId', ['userId']) .index('date', ['date']) .index('userDate', ['userId', 'date']), supportTickets: defineTable({ userId: v.id('users'), title: v.string(), description: v.string(), category: v.union( v.literal('registration'), v.literal('payment'), v.literal('technical'), v.literal('activities'), v.literal('certificates'), v.literal('accommodation'), v.literal('other') ), priority: v.union( v.literal('low'), v.literal('medium'), v.literal('high'), v.literal('urgent') ), status: v.union( v.literal('open'), v.literal('in_progress'), v.literal('waiting_user'), v.literal('resolved'), v.literal('closed') ), assignedTo: v.optional(v.array(v.id('users'))), resolvedAt: v.optional(v.number()), closedAt: v.optional(v.number()), createdAt: v.number(), updatedAt: v.number(), }) .index('userId', ['userId']) .index('status', ['status']) .index('assignedTo', ['assignedTo']), ticketMessages: defineTable({ ticketId: v.id('supportTickets'), userId: v.id('users'), message: v.string(), attachments: v.optional(v.array(v.id('_storage'))), isInternal: v.boolean(), createdAt: v.number(), }) .index('ticketId', ['ticketId']) .index('userId', ['userId']), globalConfig: defineTable({ key: v.string(), value: v.union(v.string(), v.number(), v.boolean()), description: v.string(), updatedBy: v.id('users'), updatedAt: v.number(), }).index('key', ['key']), invitations: defineTable({ email: v.string(), packageId: v.id('packages'), invitedBy: v.id('users'), // Invitation details code: v.string(), status: v.union( v.literal('pending'), v.literal('accepted'), v.literal('expired'), v.literal('cancelled') ), // Expiration and usage expiresAt: v.number(), acceptedAt: v.optional(v.number()), usedToCreateUserId: v.optional(v.id('users')), // Optional personal details (can be pre-filled) firstName: v.optional(v.string()), lastName: v.optional(v.string()), civilite: v.optional( v.union( v.literal('M.'), v.literal('Mme'), v.literal('Dr'), v.literal('Pr'), v.literal('Autre'), v.literal('Non-divulgué') ) ), createdAt: v.number(), updatedAt: v.number(), }) .index('email', ['email']) .index('code', ['code']) .index('status', ['status']) .index('invitedBy', ['invitedBy']), packages: defineTable({ name: v.string(), price: v.number(), description: v.string(), // Activity limits (for backend booking validation) workshopEntries: v.number(), conferenceEntries: v.union(v.number(), v.literal('all')), discussionEntries: v.number(), priorityWorkshopAccess: v.boolean(), // Credentials and certificates emcCredits: v.boolean(), printedDiploma: v.boolean(), digitalDiploma: v.boolean(), // Social and extras operaInvitation: v.boolean(), specialGift: v.boolean(), welcomeCocktail: v.boolean(), coffeeBreakAndLunch: v.boolean(), openingClosingCeremonies: v.boolean(), eventsAndParties: v.boolean(), congressBagAndGoodies: v.boolean(), // Landing page display data landingData: v.object({ featured: v.optional(v.boolean()), secondary: v.optional(v.boolean()), disclaimer: v.optional(v.string()), highlights: v.array( v.object({ description: v.string(), disabled: v.optional(v.boolean()), }) ), displayOrder: v.number(), badgeText: v.optional(v.string()), }), // Availability and status isActive: v.boolean(), maxQuantity: v.optional(v.number()), availableFrom: v.optional(v.number()), availableUntil: v.optional(v.number()), createdAt: v.number(), updatedAt: v.number(), }) .index('name', ['name']) .index('isActive', ['isActive']), abstractAddons: defineTable({ name: v.string(), price: v.number(), features: v.array( v.object({ name: v.string(), includedInPackages: v.array(v.string()), // package names that already include this feature }) ), isActive: v.boolean(), createdAt: v.number(), updatedAt: v.number(), }).index('isActive', ['isActive']), promotions: defineTable({ code: v.string(), name: v.string(), discountType: v.union(v.literal('fixed'), v.literal('percentage')), packageDiscounts: v.array( v.object({ packageName: v.string(), discountAmount: v.number(), }) ), isActive: v.boolean(), featured: v.boolean(), validFrom: v.optional(v.number()), validUntil: v.optional(v.number()), maxUses: v.optional(v.number()), currentUses: v.number(), createdAt: v.number(), updatedAt: v.number(), }) .index('code', ['code']) .index('isActive', ['isActive']) .index('featured', ['featured']), orders: defineTable({ userId: v.optional(v.id('users')), packageId: v.id('packages'), promotionId: v.optional(v.id('promotions')), // Pricing finalPrice: v.number(), // Add-ons withAbstract: v.boolean(), // Personal information civilite: v.union( v.literal('M.'), v.literal('Mme'), v.literal('Dr'), v.literal('Pr'), v.literal('Autre'), v.literal('Non-divulgué') ), firstName: v.string(), lastName: v.string(), email: v.string(), phone: v.string(), // Billing address address: v.string(), city: v.string(), postalCode: v.string(), country: v.string(), // Legal acceptances acceptedTerms: v.boolean(), acceptedPrivacy: v.boolean(), acceptanceTimestamp: v.number(), // Order status status: v.union( v.literal('pending'), v.literal('confirmed'), v.literal('cancelled'), v.literal('failed'), v.literal('refunded'), v.literal('requires_3ds') ), // Payment method paymentMethod: v.optional( v.union(v.literal('online'), v.literal('manual')) ), manualPaymentNote: v.optional(v.string()), manualPaymentReceivedBy: v.optional(v.id('users')), // Sandbox mode indicator isSandbox: v.optional(v.boolean()), paymentIntentId: v.optional(v.string()), paymentCompletedAt: v.optional(v.number()), // Netopia payment information netopiaId: v.optional(v.string()), netopiaToken: v.optional(v.string()), netopiaStatus: v.optional(v.number()), netopiaAuthUrl: v.optional(v.string()), netopiaAuthToken: v.optional(v.string()), paymentResponse: v.optional(v.any()), // Payment error details paymentDetails: v.optional( v.object({ action: v.optional(v.string()), errorCode: v.optional(v.string()), errorMessage: v.optional(v.string()), transactionId: v.optional(v.string()), }) ), createdAt: v.number(), updatedAt: v.number(), }) .index('userId', ['userId']) .index('email', ['email']) .index('status', ['status']) .index('packageId', ['packageId']), emailRateLimit: defineTable({ email: v.string(), type: v.union(v.literal('magic_link'), v.literal('otp')), lastSentAt: v.number(), hourlyCount: v.number(), hourWindowStartAt: v.number(), dailyCount: v.number(), dayWindowStartAt: v.number(), blockedUntil: v.optional(v.number()), }) .index('email_type', ['email', 'type']) .index('email', ['email']), universities: defineTable({ name: v.string(), countryCode: v.string(), // ISO 3166-1 alpha-2 country code isActive: v.boolean(), }) .index('countryCode', ['countryCode']) .index('isActive', ['isActive']), specialties: defineTable({ name: v.string(), emoji: v.string(), // Playful emoji for the specialty courseOfStudy: v.union(v.literal('Médecine'), v.literal('Dentaire')), isActive: v.boolean(), }) .index('isActive', ['isActive']) .index('courseOfStudy', ['courseOfStudy']), });