diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 53f0d07..25870b6 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -4,10 +4,12 @@ on: push: branches: - main + - develop paths: ["website/**"] pull_request: branches: - main + - develop paths: ["website/**"] jobs: diff --git a/deploy/Dockerfile.website b/deploy/Dockerfile.website index 093dbfd..b9d0681 100644 --- a/deploy/Dockerfile.website +++ b/deploy/Dockerfile.website @@ -2,6 +2,9 @@ FROM node:22-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" + +RUN npm install -g corepack@latest + RUN corepack enable WORKDIR /app diff --git a/deploy/website/entrypoint.sh b/deploy/website/entrypoint.sh index 723139a..08a9617 100755 --- a/deploy/website/entrypoint.sh +++ b/deploy/website/entrypoint.sh @@ -1,6 +1,7 @@ -#!/bin/sh +#!/bin/bash set -e +set -o pipefail # Script configuration @@ -28,9 +29,13 @@ run_command() { echo print "# $command" + trap "print_file $_LOG_FILE" EXIT + # Strip any ANSI escape sequences from the output eval "$command" 2>&1 | sed "s/\x1B\[[0-9;]*[a-zA-Z]//g" > "$_LOG_FILE" + trap - EXIT + print_file "$_LOG_FILE" echo } @@ -120,11 +125,13 @@ print ">> Application preparation" push_indent " " if [ "$ON_STARTUP_MIGRATE" = "true" ]; then - print ">> MIGRATE_ON_STARTUP is enabled" + mode="${ON_STARTUP_MIGRATE_MODE:-run}" + + print ">> ON_STARTUP_MIGRATE is enabled [mode: $mode]" push_indent " " print "Migrating database..." - run_command node ace migration:run --force + run_command node ace migration:$mode --force pop_indent fi diff --git a/deploy/website/railway.json b/deploy/website/railway.json new file mode 100644 index 0000000..f742d91 --- /dev/null +++ b/deploy/website/railway.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "./deploy/Dockerfile.website" + } +} diff --git a/docker-compose.coolify.yaml b/docker-compose.coolify.yaml index d5bebe1..17ebc20 100644 --- a/docker-compose.coolify.yaml +++ b/docker-compose.coolify.yaml @@ -18,11 +18,23 @@ services: - "LOG_LEVEL=${LOG_LEVEL:-info}" - "APP_KEY=${APP_KEY}" - "SESSION_DRIVER=${SESSION_DRIVER:-cookie}" + - "REDIS_HOST=${REDIS_HOST:-valkey}" + - "REDIS_PORT=${REDIS_PORT:-6379}" + - "REDIS_PASSWORD=${REDIS_PASSWORD}" - "FROM_EMAIL=${FROM_EMAIL:-noreply@eneiconf.pt}" - "SMTP_HOST=${SMTP_HOST}" - "SMTP_PORT=${SMTP_PORT}" - "INERTIA_PUBLIC_TZ=${INERTIA_PUBLIC_TZ:-Europe/Lisbon}" - "INERTIA_PUBLIC_EVENT_COUNTDOWN_DATE=${INERTIA_PUBLIC_EVENT_COUNTDOWN_DATE:-2025-04-11}" + valkey: + image: valkey/valkey:8-alpine + command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"] + volumes: + - valkey-data:/data + environment: + - "VALKEY_EXTRA_FLAGS=${VALKEY_EXTRA_FLAGS}" + volumes: - website-tmp: \ No newline at end of file + website-tmp: + valkey-data: diff --git a/docker-compose.yaml b/docker-compose.yaml index ad65edf..64ae05b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,4 +4,15 @@ services: container_name: enei-mailpit ports: - "1025:1025" - - "8025:8025" \ No newline at end of file + - "8025:8025" + + valkey: + image: valkey/valkey:8-alpine + container_name: enei-valkey + volumes: + - valkey-data:/data + ports: + - "6379:6379" + +volumes: + valkey-data: \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f326000 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "enei", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/website/.env.example b/website/.env.example index 75d73a3..9e0c7cc 100644 --- a/website/.env.example +++ b/website/.env.example @@ -10,15 +10,45 @@ NODE_ENV=development # Public facing app environment variables APP_KEY= +# Payments +IFTHENPAY_MBWAY_KEY=******** + # Session SESSION_DRIVER=cookie +# Jobs +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + # E-mail FROM_EMAIL=noreply@eneiconf.pt +REPLY_TO_EMAIL=geral@eneiconf.pt SMTP_HOST=localhost SMTP_PORT=1025 +# Rate limiting +LIMITER_STORE=memory + +# Redis +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Ally +GITHUB_CLIENT_ID=******** +GITHUB_CLIENT_SECRET=******** +GOOGLE_CLIENT_ID=******** +GOOGLE_CLIENT_SECRET=******** +LINKEDIN_CLIENT_ID=******** +LINKEDIN_CLIENT_SECRET=******** + +# Feature flags +FEATURES_DISABLE_AUTH=false + # Frontend INERTIA_PUBLIC_TZ=Europe/Lisbon INERTIA_PUBLIC_EVENT_COUNTDOWN_DATE=2025-04-11 -INERTIA_PUBLIC_APP_URL=http://127.0.0.1:3333 + +# Tuyau +INERTIA_PUBLIC_APP_URL=http://127.0.0.1:3333 \ No newline at end of file diff --git a/website/.gitignore b/website/.gitignore index 1c92932..d11cdee 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -24,3 +24,5 @@ yarn-error.log # Platform specific .DS_Store + +dump.rdb diff --git a/website/README.md b/website/README.md index f004ee4..ffab576 100644 --- a/website/README.md +++ b/website/README.md @@ -27,19 +27,25 @@ To get started with the website, follow these steps: cd enei/website ``` -3. Install the dependencies: +3. Install `pnpm`: + + ```bash + corepack enable + ``` + +4. Install the dependencies: ```bash pnpm install --frozen-lockfile ``` -4. Run the database migrations to create the database: +5. Run the database migrations to create the database: ```bash node ace migration:run ``` -5. Start the development server: +6. Start the development server: ```bash pnpm run dev diff --git a/website/adonisrc.ts b/website/adonisrc.ts index 8a1efd4..f595eb7 100644 --- a/website/adonisrc.ts +++ b/website/adonisrc.ts @@ -15,6 +15,7 @@ export default defineConfig({ () => import('@adonisjs/lucid/commands'), () => import('@adonisjs/mail/commands'), () => import('@tuyau/core/commands'), + () => import('adonisjs-jobs/commands'), ], /* @@ -43,8 +44,12 @@ export default defineConfig({ () => import('@adonisjs/lucid/database_provider'), () => import('@adonisjs/auth/auth_provider'), () => import('@adonisjs/inertia/inertia_provider'), + () => import('adonisjs-jobs/jobs_provider'), () => import('@adonisjs/mail/mail_provider'), () => import('@tuyau/core/tuyau_provider'), + () => import('@adonisjs/ally/ally_provider'), + () => import('@adonisjs/limiter/limiter_provider'), + () => import('@adonisjs/redis/redis_provider'), ], /* @@ -55,7 +60,12 @@ export default defineConfig({ | List of modules to import before starting the application. | */ - preloads: [() => import('#start/routes'), () => import('#start/kernel')], + preloads: [ + () => import('#start/routes'), + () => import('#start/kernel'), + () => import('#start/events'), + () => import('#start/validator'), + ], /* |-------------------------------------------------------------------------- diff --git a/website/app/controllers/authentication_controller.ts b/website/app/controllers/authentication_controller.ts new file mode 100644 index 0000000..644cd89 --- /dev/null +++ b/website/app/controllers/authentication_controller.ts @@ -0,0 +1,147 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { + registerWithCredentialsValidator, + emailVerificationCallbackValidator, + loginWithCredentialsValidator, + passwordResetValidator, + passwordSendForgotPasswordValidator, +} from '#validators/authentication' +import { UserService } from '#services/user_service' +import { inject } from '@adonisjs/core' +import UserRequestedVerificationEmail from '#events/user_requested_verification_email' +import Account from '#models/account' + +@inject() +export default class AuthenticationController { + constructor(private userService: UserService) { } + + async login({ request, auth, session, response }: HttpContext) { + const { email, password } = await request.validateUsing(loginWithCredentialsValidator) + + const user = await this.userService.getUserWithCredentials(email, password) + if (!user) { + session.flashErrors({ password: 'As credenciais que introduziste não são válidas' }) + return response.redirect().back() + } + + await auth.use('web').login(user) + + return user.isEmailVerified() + ? response.redirect().toRoute('pages:home') + : response.redirect().toRoute('pages:auth.verify') + } + + async logout({ auth, response }: HttpContext) { + await auth.use('web').logout() + return response.redirect().toRoute('pages:home') + } + + async register({ request, auth, response }: HttpContext) { + const { email, password } = await request.validateUsing(registerWithCredentialsValidator) + + const [user, events] = await this.userService.createUserWithCredentials(email, password) + const [success] = await events + if (!success) { + + } + + await auth.use('web').login(user) + + return response.redirect().toRoute('pages:auth.verify') + } + + async retryEmailVerification({ auth, response }: HttpContext) { + const user = auth.getUserOrFail() + + UserRequestedVerificationEmail.tryDispatch(user) + + return response.redirect().toRoute('pages:auth.verify') + } + + async callbackForEmailVerification({ request, response }: HttpContext) { + const { email } = await request.validateUsing(emailVerificationCallbackValidator) + await this.userService.verifyEmail(email) + + return response.redirect().toRoute('pages:auth.verify.success') + } + + async sendForgotPassword({ request, response }: HttpContext) { + const { email } = await request.validateUsing(passwordSendForgotPasswordValidator) + + /* + According to OWASP recommendations, the existence of the account should be transparent + to the person who issues this request, but we should not send an email that is not in + any account. + */ + if (await Account.findBy('id', `credentials:${email}`)) { + await this.userService.sendForgotPasswordEmail(email) + } + + return response.redirect().toRoute('page:auth.forgot-password.sent') + } + + async callbackForForgotPassword({ request, response }: HttpContext) { + const { + password, + email + } = await request.validateUsing(passwordResetValidator) + + const account = await Account.find(`credentials:${email}`) + if (account) { + account.password = password // Auther mixin hashes it automatically on assignment + await account.save() + } + + return response.redirect().toRoute('actions:auth.forgot-password.success') + } + + async showForgotPasswordPage({ inertia }: HttpContext) { + return inertia.render('auth/forgot-password/reset') + } + + // SOCIAL AUTHENTICATION + + // async initiateGithubLogin({ ally, inertia }: HttpContext) { + // const url = await ally.use('github').redirectUrl() + // return inertia.location(url) + // } + + // async callbackForGithubLogin({ ally }: HttpContext) { + // const github = ally.use('github') + // const user = await github.user() + + // const data = await socialAccountLoginValidator.validate(user) + // console.log(data) + + // const account = await getOrCreate({ + // provider: 'github', + // providerId: data.id, + // }) + + // return response.json({ user, account: account.serialize() }) + // } + + // async initiateGoogleLogin({ ally, inertia }: HttpContext) { + // const url = await ally.use('google').redirectUrl() + // return inertia.location(url) + // } + + // async callbackForGoogleLogin({ response, ally }: HttpContext) { + // const google = ally.use('google') + // const user = await google.user() + + // return response.json({ user }) + // } + + // async initiateLinkedinLogin({ ally, inertia }: HttpContext) { + // const url = await ally.use('linkedin').redirectUrl() + // return inertia.location(url) + // } + + // async callbackForLinkedinLogin({ response, ally }: HttpContext) { + // const linkedin = ally.use('linkedin') + // const user = await linkedin.user() + + // return response.json({ user }) + // } +} diff --git a/website/app/controllers/orders_controller.ts b/website/app/controllers/orders_controller.ts new file mode 100644 index 0000000..f0b8f42 --- /dev/null +++ b/website/app/controllers/orders_controller.ts @@ -0,0 +1,188 @@ +import type { HttpContext } from '@adonisjs/core/http' +import env from '#start/env' +import axios from 'axios' +import Order from '#models/order' +import User from '#models/user' +import OrderProduct from '#models/order_product' +import Product from '#models/product' +import ProductGroup from '#models/product_group' +import { createMBWayOrderValidator } from '#validators/order' +import UpdateOrderStatus from '../jobs/update_order_status.js' +export default class OrdersController { + index({ inertia }: HttpContext) { + return inertia.render('payments') + } + + public async createMBWay({ request, auth, response }: HttpContext) { + const authUser = auth.user + + try { + // Validate input format + await request.validateUsing(createMBWayOrderValidator) + + const { userId, products, name, nif, address, mobileNumber } = request.all() + + // Validate authentication + + if (!authUser || authUser.id !== userId) { + return response.status(401).json({ message: 'Não autorizado' }) + } + + // Validate user existence + + const user = await User.find(userId) + + if (!user) { + return response.status(404).json({ message: 'Utilizador não encontrado' }) + } + + let totalAmount = 0 + let description = '' + + const productDetails = [] + + for (const productItem of products) { + const { productId, quantity } = productItem + const product = await Product.find(productId) + + if (!product) { + return response + .status(404) + .json({ message: `Produto com id ${productId} não foi encontrado` }) + } + + const successfulOrdersOfGivenProduct = await OrderProduct.query() + .join('orders', 'order_products.order_id', 'orders.id') + .where('order_products.product_id', productId) + .whereIn('orders.status', ['Success', 'Pending']) + + const successfulOrdersOfGivenProductPerUser = await OrderProduct.query() + .join('orders', 'order_products.order_id', 'orders.id') + .where('orders.user_id', userId) + .where('order_products.product_id', productId) + .whereIn('orders.status', ['Success', 'Pending']) + + const stockUsed = successfulOrdersOfGivenProduct.reduce( + (acc, orderProduct) => acc + orderProduct.quantity, + 0 + ) + + const totalQuantity = successfulOrdersOfGivenProductPerUser.reduce( + (acc, orderProduct) => acc + orderProduct.quantity, + 0 + ) + + if (product.stock < quantity + stockUsed) { + return response + .status(400) + .json({ message: `Não há mais stock do produto ${product.name}` }) + } + + if (quantity + totalQuantity > product.max_order) { + return response.status(400).json({ + message: `Apenas podes comprar ${product.max_order} do produto ${product.name}`, + }) + } + + const productGroup = await ProductGroup.find(product.productGroupId) + if(productGroup){ + + const sucessfulOrdersOfGivenGroup = await OrderProduct.query() + .join('orders', 'order_products.order_id', 'orders.id') + .join('products', 'order_products.product_id', 'products.id') + .where('orders.user_id', userId) + .where('products.product_group_id', product.productGroupId) + .where('orders.status', 'Success') + + + + const totalGroupQuantity = sucessfulOrdersOfGivenGroup.reduce( + (acc, orderProduct) => acc + orderProduct.quantity, + 0 + ) + + if (totalGroupQuantity + quantity > productGroup.maxAmountPerGroup) { + return response.status(400).json({ + message: `Apenas podes comprar ${productGroup?.maxAmountPerGroup} produtos do grupo ${productGroup.name}`, + }) + + } + } + productDetails.push({ product, quantity }) + totalAmount += product.price * quantity + description += `${product.name} x${quantity}, ` + } + + description = `Payment for order: ${description.slice(0, -2)}` + + // Create the order and associated products + const order = await Order.create({ userId, name, nif, address }) + + for (const { product, quantity } of productDetails) { + await OrderProduct.create({ + orderId: order.id, + productId: product.id, + quantity, + }) + } + + // Prepare payment data + + const data = { + mbWayKey: env.get('IFTHENPAY_MBWAY_KEY'), + orderId: order.id, + amount: totalAmount.toFixed(2), + mobileNumber, + description, + } + + // Call payment API + + const apiResponse = await axios.post('https://api.ifthenpay.com/spg/payment/mbway', data) + + if (apiResponse.status === 200) { + const responseData = apiResponse.data + order.requestId = responseData.RequestId + order.status = 'Pending' + order.total = totalAmount + await order.save() + + // Dispatch background job to update order status + + await UpdateOrderStatus.dispatch( + { requestId: order.requestId, email: authUser.email }, + { delay: 10000 } + ).catch((error) => { + console.error('Error dispatching job', error) + }) + + return response.status(200).json({ + order, + message: 'Payment initiated successfully', + }) + } else { + return response.status(500).json({ message: 'Failed to initiate payment' }) + } + } catch (error) { + console.error(error) + return response.status(500).json({ + message: 'An error occurred while initiating the payment', + }) + } + } + + public async show({ inertia, params, auth, response }: HttpContext) { + const authUser = auth.user + if (!authUser) { + return response.status(401).json({ + message: 'Unauthorized', + }) + } + + const order = await Order.find(params.id) + if (!order || (order.userId !== authUser.id)) { + return response.notFound({ message: 'Order not found' }) + } + return inertia.render('payments/show', { order }) + } +} diff --git a/website/app/controllers/profiles_controller.ts b/website/app/controllers/profiles_controller.ts new file mode 100644 index 0000000..3bd9f79 --- /dev/null +++ b/website/app/controllers/profiles_controller.ts @@ -0,0 +1,40 @@ +import ParticipantProfile from '#models/participant_profile' +import { createProfileValidator } from '#validators/profile' +import type { HttpContext } from '@adonisjs/core/http' + +export default class ProfilesController { + // To be used when the profile page is done + async index({ auth, inertia }: HttpContext) { + const user = auth.user! + await user.load('participantProfile') + return inertia.render('profile', user.participantProfile!) + } + + async show({ inertia }: HttpContext) { + return inertia.render('signup') + } + + async create({ auth, request, response }: HttpContext) { + const user = auth.getUserOrFail() + + const data = request.body() + data.finishedAt = data.curricularYear[1] + data.curricularYear = data.curricularYear[0] + // HACK + data.transports = data.transports + .map((item: { label: string; value: string; }) => item.value) + data.attendedBeforeEditions = data.attendedBeforeEditions + .map((item: { label: string; value: string; }) => item.value) + data.dietaryRestrictions ||= "" + data.reasonForSignup ||= "" + + const profile = await createProfileValidator.validate(data) + + const profileAdd = new ParticipantProfile() + profileAdd.fill(profile) + + await user.related('participantProfile').associate(profileAdd) + + return response.redirect().toRoute('pages:tickets') + } +} diff --git a/website/app/controllers/tickets_controller.ts b/website/app/controllers/tickets_controller.ts index 1a3a5be..8f3c485 100644 --- a/website/app/controllers/tickets_controller.ts +++ b/website/app/controllers/tickets_controller.ts @@ -1,10 +1,15 @@ -import Ticket from '#models/ticket' +import Product from '#models/product' import type { HttpContext } from '@adonisjs/core/http' - export default class TicketsController { async index({ inertia }: HttpContext) { - const ticketTypes = await Ticket.all() + const ticketTypes = await Product.all() return inertia.render('tickets', { ticketTypes }) } + + async showPayment({ inertia, auth, params }: HttpContext) { + const ticket = await Product.find(params.id) + + return inertia.render('payments', { ticket, user: auth.user }) + } } diff --git a/website/app/env.ts b/website/app/env.ts index 72a90ad..c1f4e61 100644 --- a/website/app/env.ts +++ b/website/app/env.ts @@ -1,8 +1,8 @@ import vine from '@vinejs/vine' -import { ConstructableSchema, SchemaTypes } from '@vinejs/vine/types' +import type { ConstructableSchema, SchemaTypes } from '@vinejs/vine/types' import { EnvProcessor, Env as AdonisEnv } from '@adonisjs/core/env' -type Primitives = string | number | boolean +type Primitives = string | number | boolean | null | undefined function createObjectInterceptor() { const keys = new Set() diff --git a/website/app/events/user_created.ts b/website/app/events/user_created.ts new file mode 100644 index 0000000..d69af5a --- /dev/null +++ b/website/app/events/user_created.ts @@ -0,0 +1,8 @@ +import { BaseEvent } from '#lib/adonisjs/events.js' +import User from '#models/user' + +export default class UserCreated extends BaseEvent { + constructor(public readonly user: User) { + super() + } +} diff --git a/website/app/events/user_email_verified.ts b/website/app/events/user_email_verified.ts new file mode 100644 index 0000000..c41cb24 --- /dev/null +++ b/website/app/events/user_email_verified.ts @@ -0,0 +1,8 @@ +import type User from '#models/user' +import { BaseEvent } from '#lib/adonisjs/events.js' + +export default class UserEmailVerified extends BaseEvent { + constructor(public readonly user: User) { + super() + } +} diff --git a/website/app/events/user_forgot_password.ts b/website/app/events/user_forgot_password.ts new file mode 100644 index 0000000..eede4db --- /dev/null +++ b/website/app/events/user_forgot_password.ts @@ -0,0 +1,7 @@ +import { BaseEvent } from '@adonisjs/core/events' + +export default class UserForgotPassword extends BaseEvent { + constructor(public readonly email: string) { + super() + } +} \ No newline at end of file diff --git a/website/app/events/user_requested_verification_email.ts b/website/app/events/user_requested_verification_email.ts new file mode 100644 index 0000000..90905da --- /dev/null +++ b/website/app/events/user_requested_verification_email.ts @@ -0,0 +1,8 @@ +import type User from '#models/user' +import { BaseEvent } from '#lib/adonisjs/events.js' + +export default class UserRequestedVerificationEmail extends BaseEvent { + constructor(public readonly user: User) { + super() + } +} diff --git a/website/app/exceptions/authentication_disabled_exception.ts b/website/app/exceptions/authentication_disabled_exception.ts new file mode 100644 index 0000000..24f23ca --- /dev/null +++ b/website/app/exceptions/authentication_disabled_exception.ts @@ -0,0 +1,8 @@ +import { Exception } from '@adonisjs/core/exceptions' + +export default class AuthenticationDisabledException extends Exception { + static status = 403 + static code = 'E_AUTH_DISABLED' + static message = 'Authentication is disabled' + static help = 'Did you forget to enable authentication in your .env file?' +} diff --git a/website/app/exceptions/handler.ts b/website/app/exceptions/handler.ts index 0ab9d3b..ddddf36 100644 --- a/website/app/exceptions/handler.ts +++ b/website/app/exceptions/handler.ts @@ -16,13 +16,19 @@ export default class HttpExceptionHandler extends ExceptionHandler { */ protected renderStatusPages = app.inProduction + protected ignoreCodes = ['E_AUTH_DISABLED'] + /** * Status pages is a collection of error code range and a callback * to return the HTML contents to send as a response. */ protected statusPages: Record = { - '404': (error, { inertia }) => inertia.render('errors/not_found', { error }), - '500..599': (error, { inertia }) => inertia.render('errors/server_error', { error }), + // '403': (error, { inertia }) => inertia.render('errors/forbidden', { error }), + '403': (_error, { response }) => response.status(403).finish(), + // '404': (error, { inertia }) => inertia.render('errors/not_found', { error }), + '404': (_error, { response }) => response.status(404).finish(), + // '500..599': (error, { inertia }) => inertia.render('errors/server_error', { error }), + '500..599': (_error, { response }) => response.status(500).finish(), } /** diff --git a/website/app/jobs/update_order_status.ts b/website/app/jobs/update_order_status.ts new file mode 100644 index 0000000..140a936 --- /dev/null +++ b/website/app/jobs/update_order_status.ts @@ -0,0 +1,74 @@ +import axios from 'axios' +import Order from '#models/order' +import env from '#start/env' +import { Job } from 'adonisjs-jobs' +import ConfirmPaymentNotification from '#mails/confirm_payment_notification' +import mail from '@adonisjs/mail/services/main' +import db from '@adonisjs/lucid/services/db' + +type UpdateOrderStatusPayload = { + requestId: string + email: string +} + +export default class UpdateOrderStatus extends Job { + async handle({ requestId, email }: UpdateOrderStatusPayload) { + try { + + this.logger.info(`Processing status update for requestId: ${requestId}`) + + // Fetch the order based on the requestId + const order = await Order.query().where('request_id', requestId).first() + if (!order) { + this.logger.error(`Order with requestId ${requestId} not found`) + console.error(`Order with requestId ${requestId} not found`) + return + } + + if (order.status !== 'Pending') { + this.logger.info(`Order status is no longer pending: ${order.status}`) + return // Exit if the status is no longer "Pending" + } + const apiResponse = await axios.get( + `https://api.ifthenpay.com/spg/payment/mbway/status?mbWayKey=${env.get('IFTHENPAY_MBWAY_KEY')}&requestId=${requestId}` + ) + + if (apiResponse.status === 200) { + const status = apiResponse.data.Message + if (status) { + if (status === 'Pending') { + await UpdateOrderStatus.dispatch({ requestId, email }, { delay: 10000 }) // Retry after 5 seconds + this.logger.info(`Requeued job for requestId: ${requestId}`) + return + } + order.status = status + await order.save() + this.logger.info(`Order status updated to: ${order.status}`) + if (order.status === 'Success') { + this.logger.info(`Gonna send mail: ${order.status}`) + const products = await db + .from('products') + .join('order_products', 'products.id', 'order_products.product_id') + .where('order_products.order_id', order.id) + .select('products.*', 'order_products.quantity as quantity') + + const total = order.total + const orderId = order.id + await mail.send(new ConfirmPaymentNotification(email, products, total, orderId)) + } + } else { + await UpdateOrderStatus.dispatch({ requestId, email }, { delay: 10000 }) // Retry after 5 seconds + } + } else { + this.logger.error(`Failed to fetch payment status for requestId: ${requestId}`) + console.error(`Failed to fetch payment status for requestId: ${requestId}`) + await UpdateOrderStatus.dispatch({ requestId, email }, { delay: 10000 }) // Retry after 5 seconds + } + } catch (error) { + this.logger.error(`Error updating order status: ${error.message}`) + console.error(`Error updating order status: ${error.message}`) + + await UpdateOrderStatus.dispatch({ requestId, email }, { delay: 10000 }) + } + } +} diff --git a/website/app/listeners/send_forgot_password_email.ts b/website/app/listeners/send_forgot_password_email.ts new file mode 100644 index 0000000..3a66171 --- /dev/null +++ b/website/app/listeners/send_forgot_password_email.ts @@ -0,0 +1,22 @@ +import mail from "@adonisjs/mail/services/main"; +import { buildUrl, staticUrl } from "../url.js"; +import type UserForgotPassword from "#events/user_forgot_password"; +import ForgotPasswordNotification from "#mails/forgot_password_notification"; + +export default class SendForgotPasswordEmail { + async handle(event: UserForgotPassword) { + + const email = event.email; + const notification = new ForgotPasswordNotification({ + email, + logoUrl: staticUrl("/images/logo-white.png"), + + verificationLink: buildUrl() + .qs({ email }) + .makeSigned("pages:auth.forgot-password.callback", { expiresIn: "10m" }), + }); + + await mail.send(notification); + } +} + diff --git a/website/app/listeners/send_verification_email.ts b/website/app/listeners/send_verification_email.ts new file mode 100644 index 0000000..19f9b5e --- /dev/null +++ b/website/app/listeners/send_verification_email.ts @@ -0,0 +1,24 @@ +import UserCreated from '#events/user_created' +import EmailVerificationNotification from '#mails/email_verification_notification' +import mail from '@adonisjs/mail/services/main' +import { buildUrl, staticUrl } from '../url.js' +import type UserRequestedVerificationEmail from '#events/user_requested_verification_email' + +export default class SendVerificationEmail { + async handle(event: UserCreated | UserRequestedVerificationEmail) { + // Don't send the verification e-mail if the user has already verified it + if (event.user.emailVerifiedAt) return + + const email = event.user.email + const notification = new EmailVerificationNotification({ + email, + logoUrl: staticUrl('/images/logo-white.png'), + + verificationLink: buildUrl() + .qs({ email }) + .makeSigned('actions:auth.verify.callback', { expiresIn: '1h' }), + }) + + await mail.send(notification) + } +} diff --git a/website/app/mails/base/react_notification.ts b/website/app/mails/base/react_notification.ts new file mode 100644 index 0000000..937e241 --- /dev/null +++ b/website/app/mails/base/react_notification.ts @@ -0,0 +1,16 @@ +import { BaseMail } from '@adonisjs/mail' +import { render } from '@react-email/components' +import type { JSX } from 'react' + +type JSXImport = () => Promise<{ default: (props: T) => JSX.Element }> + +export abstract class ReactNotification extends BaseMail { + async jsx(importer: JSXImport, props: NoInfer) { + const component = await importer().then((mod) => mod.default) + const element = component(props) + + this.message.html(await render(element)).text(await render(element, { plainText: true })) + } + + abstract prepare(): void | Promise +} diff --git a/website/app/mails/confirm_payment_notification.ts b/website/app/mails/confirm_payment_notification.ts new file mode 100644 index 0000000..1fc66f8 --- /dev/null +++ b/website/app/mails/confirm_payment_notification.ts @@ -0,0 +1,36 @@ +import env from '#start/env' +import { ReactNotification } from './base/react_notification.js' +import type { ProductWithQuantity, MailProps } from '#resources/emails/payment/confirm_purchase_email' +import { staticUrl } from '../url.js' + +export default class ConfirmPaymentNotification extends ReactNotification { + private userEmail: string + private products: ProductWithQuantity[] + private total: number + private orderId: number + from = env.get('FROM_EMAIL') + subject = 'Your Payment was completed with Success' + + constructor(userEmail: string, products: ProductWithQuantity[], total: number, orderId: number) { + super() + this.userEmail = userEmail + this.products = products + this.total = total + this.orderId = orderId + } + + get props(): MailProps { + return { + logoUrl: staticUrl('/images/logo-white.png'), + userEmail: this.userEmail, + products: this.products, + total: this.total, + orderId: this.orderId, + } + } + + async prepare() { + this.message.to(this.userEmail) + await this.jsx(() => import('#resources/emails/payment/confirm_purchase_email'), this.props) + } +} diff --git a/website/app/mails/email_verification_notification.ts b/website/app/mails/email_verification_notification.ts new file mode 100644 index 0000000..1352e86 --- /dev/null +++ b/website/app/mails/email_verification_notification.ts @@ -0,0 +1,14 @@ +import { ReactNotification } from './base/react_notification.js' +import type { EmailVerificationProps } from '#resources/emails/auth/email_verification' + +export default class EmailVerificationNotification extends ReactNotification { + constructor(private props: EmailVerificationProps) { + super() + } + + async prepare() { + this.message.to(this.props.email).subject('Confirma o teu e-mail!') + + await this.jsx(() => import('#resources/emails/auth/email_verification'), this.props) + } +} diff --git a/website/app/mails/example_e_notification.ts b/website/app/mails/example_e_notification.ts deleted file mode 100644 index c78fbfd..0000000 --- a/website/app/mails/example_e_notification.ts +++ /dev/null @@ -1,31 +0,0 @@ -import env from '#start/env' -import { BaseMail } from '@adonisjs/mail' - -export default class ExampleENotification extends BaseMail { - private userEmail: string - - from = env.get('FROM_EMAIL') - subject = 'This is an example email' - - constructor(userEmail: string) { - super() - - this.userEmail = userEmail - } - - /** - * The "prepare" method is called automatically when - * the email is sent or queued. - */ - async prepare() { - this.message - .to(this.userEmail) - .subject(this.subject) - .htmlView('emails/example_email_html', { - userEmail: this.userEmail, - }) - .textView('emails/example_email_text', { - userEmail: this.userEmail, - }) - } -} diff --git a/website/app/mails/forgot_password_notification.ts b/website/app/mails/forgot_password_notification.ts new file mode 100644 index 0000000..67e2484 --- /dev/null +++ b/website/app/mails/forgot_password_notification.ts @@ -0,0 +1,15 @@ +import { ReactNotification } from './base/react_notification.js' +import type { EmailVerificationProps } from '#resources/emails/auth/email_verification' + +export default class ForgotPasswordNotification extends ReactNotification { + constructor(private props: EmailVerificationProps) { + super() + } + + async prepare() { + this.message.to(this.props.email).subject('Repõe a tua palavra-passe!') + + await this.jsx(() => import('#resources/emails/auth/forgot_password'), this.props) + } +} + diff --git a/website/app/messages.ts b/website/app/messages.ts new file mode 100644 index 0000000..620c63c --- /dev/null +++ b/website/app/messages.ts @@ -0,0 +1,8 @@ +export const messages = { + auth: { + oauth: { + accessDenied: 'O pedido de início de sessão foi rejeitado.', + stateMismatch: 'Ocorreu um erro ao iniciar sessão. Por favor, tenta novamente.', + }, + }, +} as const diff --git a/website/app/middleware/auth_middleware.ts b/website/app/middleware/auth/auth_middleware.ts similarity index 95% rename from website/app/middleware/auth_middleware.ts rename to website/app/middleware/auth/auth_middleware.ts index f5a2ba3..22cfb23 100644 --- a/website/app/middleware/auth_middleware.ts +++ b/website/app/middleware/auth/auth_middleware.ts @@ -10,7 +10,7 @@ export default class AuthMiddleware { /** * The URL to redirect to, when authentication fails */ - redirectTo = '/login' + redirectTo = '/auth/login' async handle( ctx: HttpContext, diff --git a/website/app/middleware/guest_middleware.ts b/website/app/middleware/auth/guest_middleware.ts similarity index 100% rename from website/app/middleware/guest_middleware.ts rename to website/app/middleware/auth/guest_middleware.ts diff --git a/website/app/middleware/auth/logout_if_authentication_disabled_middleware.ts b/website/app/middleware/auth/logout_if_authentication_disabled_middleware.ts new file mode 100644 index 0000000..61d9103 --- /dev/null +++ b/website/app/middleware/auth/logout_if_authentication_disabled_middleware.ts @@ -0,0 +1,13 @@ +import env from '#start/env' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class LogoutIfAuthenticationDisabledMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + if (env.get('FEATURES_DISABLE_AUTH')) { + await ctx.auth.use('web').logout() + } + + return next() + } +} diff --git a/website/app/middleware/auth/no_verified_email_middleware.ts b/website/app/middleware/auth/no_verified_email_middleware.ts new file mode 100644 index 0000000..ecff7b7 --- /dev/null +++ b/website/app/middleware/auth/no_verified_email_middleware.ts @@ -0,0 +1,14 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class NoVerifiedEmailMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + const user = ctx.auth.getUserOrFail() + + if (user.isEmailVerified()) { + return ctx.response.redirect().toRoute('pages:home') + } + + return next() + } +} \ No newline at end of file diff --git a/website/app/middleware/auth/require_authentication_enabled_middleware.ts b/website/app/middleware/auth/require_authentication_enabled_middleware.ts new file mode 100644 index 0000000..56ec5c4 --- /dev/null +++ b/website/app/middleware/auth/require_authentication_enabled_middleware.ts @@ -0,0 +1,14 @@ +import AuthenticationDisabledException from '#exceptions/authentication_disabled_exception' +import env from '#start/env' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class RequireAuthenticationEnabledMiddleware { + async handle(_ctx: HttpContext, next: NextFn) { + if (env.get('FEATURES_DISABLE_AUTH')) { + throw new AuthenticationDisabledException() + } + + return next() + } +} diff --git a/website/app/middleware/silent_auth_middleware.ts b/website/app/middleware/auth/silent_auth_middleware.ts similarity index 100% rename from website/app/middleware/silent_auth_middleware.ts rename to website/app/middleware/auth/silent_auth_middleware.ts diff --git a/website/app/middleware/auth/verified_email_middleware.ts b/website/app/middleware/auth/verified_email_middleware.ts new file mode 100644 index 0000000..1f3715c --- /dev/null +++ b/website/app/middleware/auth/verified_email_middleware.ts @@ -0,0 +1,14 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class VerifiedEmailMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + const user = ctx.auth.getUserOrFail() + + if (!user.isEmailVerified()) { + return ctx.response.redirect().toRoute('pages:auth.verify') + } + + return next() + } +} \ No newline at end of file diff --git a/website/app/middleware/auth/verify_social_callback_middleware.ts b/website/app/middleware/auth/verify_social_callback_middleware.ts new file mode 100644 index 0000000..7676f5c --- /dev/null +++ b/website/app/middleware/auth/verify_social_callback_middleware.ts @@ -0,0 +1,33 @@ +import type { SocialProviders } from '@adonisjs/ally/types' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class VerifySocialCallbackMiddleware { + async handle( + { response, session, ally }: HttpContext, + next: NextFn, + options: { provider: keyof SocialProviders } + ) { + const oauth = ally.use(options.provider) + + if (oauth.accessDenied()) { + // session.flashErrors({ oauth: messages.auth.oauth.accessDenied }) + return response.redirect('/login') + } + + if (oauth.stateMisMatch()) { + // session.flashErrors({ oauth: messages.auth.oauth.stateMismatch }) + return response.redirect('/login') + } + + const postRedirectError = oauth.getError() + if (postRedirectError) { + session.flashErrors({ oauth: postRedirectError }) + return response.redirect('/login') + } + + const output = await next() + + return output + } +} diff --git a/website/app/middleware/automatic_submit_middleware.ts b/website/app/middleware/automatic_submit_middleware.ts new file mode 100644 index 0000000..a063219 --- /dev/null +++ b/website/app/middleware/automatic_submit_middleware.ts @@ -0,0 +1,12 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class AutomaticSubmitMiddleware { + async handle({ request, response, view }: HttpContext, next: NextFn) { + const method = request.method() + if (method === 'POST') return next() + + // Clever hack by virk to render Edge.js templates in middlewares + return response.status(200).send(await view.render('automatic_submit')) + } +} diff --git a/website/app/middleware/container_bindings_middleware.ts b/website/app/middleware/container_bindings_middleware.ts index 48e6d09..97abc83 100644 --- a/website/app/middleware/container_bindings_middleware.ts +++ b/website/app/middleware/container_bindings_middleware.ts @@ -1,6 +1,6 @@ import { Logger } from '@adonisjs/core/logger' import { HttpContext } from '@adonisjs/core/http' -import { NextFn } from '@adonisjs/core/types/http' +import type { NextFn } from '@adonisjs/core/types/http' /** * The container bindings middleware binds classes to their request diff --git a/website/app/middleware/finish_redirect_middleware.ts b/website/app/middleware/finish_redirect_middleware.ts new file mode 100644 index 0000000..09c7f4f --- /dev/null +++ b/website/app/middleware/finish_redirect_middleware.ts @@ -0,0 +1,15 @@ +import { getRedirect } from '#lib/redirect.js' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class FinishRedirectMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + const redirect = getRedirect(ctx) + + if (redirect) { + return ctx.response.redirect(redirect) + } + + return next() + } +} \ No newline at end of file diff --git a/website/app/middleware/log_user_middleware.ts b/website/app/middleware/log_user_middleware.ts new file mode 100644 index 0000000..9bbc341 --- /dev/null +++ b/website/app/middleware/log_user_middleware.ts @@ -0,0 +1,17 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { Logger } from '@adonisjs/core/logger' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class LogUserMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + await ctx.auth.check() + const user = ctx.auth.user + + if (user) { + ctx.logger = ctx.logger.child({ user_id: user.id }) + ctx.containerResolver.bindValue(Logger, ctx.logger) + } + + return next() + } +} diff --git a/website/app/middleware/profile/no_profile_middleware.ts b/website/app/middleware/profile/no_profile_middleware.ts new file mode 100644 index 0000000..ff6afc6 --- /dev/null +++ b/website/app/middleware/profile/no_profile_middleware.ts @@ -0,0 +1,14 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class NoProfileMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + const user = ctx.auth.getUserOrFail() + + if (user.participantProfileId !== null) { + return ctx.response.redirect().toRoute('pages:home') + } + + return await next() + } +} diff --git a/website/app/middleware/profile/participant_middleware.ts b/website/app/middleware/profile/participant_middleware.ts new file mode 100644 index 0000000..aeff248 --- /dev/null +++ b/website/app/middleware/profile/participant_middleware.ts @@ -0,0 +1,14 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class ParticipantMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + const user = ctx.auth.getUserOrFail() + + if (user.participantProfileId === null) { + return ctx.response.redirect().toRoute('pages:signup') + } + + return await next() + } +} diff --git a/website/app/middleware/update_logger_storage_middleware.ts b/website/app/middleware/update_logger_storage_middleware.ts new file mode 100644 index 0000000..edd3fa1 --- /dev/null +++ b/website/app/middleware/update_logger_storage_middleware.ts @@ -0,0 +1,9 @@ +import { loggerStorage } from '#lib/adonisjs/logger.js' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class SetupLoggerStorageMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + return loggerStorage.run(ctx.logger, next) + } +} \ No newline at end of file diff --git a/website/app/middleware/verify_url_signature_middleware.ts b/website/app/middleware/verify_url_signature_middleware.ts new file mode 100644 index 0000000..c5b81f5 --- /dev/null +++ b/website/app/middleware/verify_url_signature_middleware.ts @@ -0,0 +1,13 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class VerifyUrlSignatureMiddleware { + async handle({ request, response }: HttpContext, next: NextFn) { + if (!request.hasValidSignature()) { + return response.badRequest('Invalid or expired URL') + } + + const output = await next() + return output + } +} diff --git a/website/app/models/account.ts b/website/app/models/account.ts new file mode 100644 index 0000000..9882da8 --- /dev/null +++ b/website/app/models/account.ts @@ -0,0 +1,40 @@ +import { DateTime } from 'luxon' +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import type { SocialProviders } from '@adonisjs/ally/types' +import { compose } from '@adonisjs/core/helpers' +import hash from '@adonisjs/core/services/hash' +import User from './user.js' +import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' + +const AuthFinder = withAuthFinder(() => hash.use('scrypt'), { + uids: ['id'], + passwordColumnName: 'password', +}) + +type AccountProvider = 'credentials' | keyof SocialProviders +type AccountId = `${AccountProvider}:${string}` + +export default class Account extends compose(BaseModel, AuthFinder) { + @column({ isPrimary: true }) + declare id: AccountId + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + @column({ serializeAs: null }) + declare password: string + + @column() + declare userId: number + + @belongsTo(() => User) + declare user: BelongsTo + + static findByCredentials(email: string) { + return this.findForAuth(['id'], `credentials:${email}`) + } +} diff --git a/website/app/models/order.ts b/website/app/models/order.ts new file mode 100644 index 0000000..fbab305 --- /dev/null +++ b/website/app/models/order.ts @@ -0,0 +1,34 @@ +import { DateTime } from 'luxon' +import { BaseModel, column } from '@adonisjs/lucid/orm' + +export default class Order extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare requestId: string + + @column() + declare userId: number + + @column() + declare name: string + + @column() + declare nif: number + + @column() + declare address: string + + @column() + declare status: string + + @column() + declare total: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/website/app/models/ticket.ts b/website/app/models/order_product.ts similarity index 60% rename from website/app/models/ticket.ts rename to website/app/models/order_product.ts index 049af34..51d235a 100644 --- a/website/app/models/ticket.ts +++ b/website/app/models/order_product.ts @@ -1,25 +1,22 @@ import { DateTime } from 'luxon' import { BaseModel, column } from '@adonisjs/lucid/orm' -export default class Ticket extends BaseModel { +export default class OrderProduct extends BaseModel { @column({ isPrimary: true }) declare id: number - @column() - declare name: string | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime - @column() - declare description: string + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime @column() - declare price: number + declare orderId: number @column() - declare stock: number + declare productId: number - @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime - - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime | null + @column() + declare quantity: number } diff --git a/website/app/models/participant_profile.ts b/website/app/models/participant_profile.ts new file mode 100644 index 0000000..29f6757 --- /dev/null +++ b/website/app/models/participant_profile.ts @@ -0,0 +1,78 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, hasOne } from '@adonisjs/lucid/orm' +import User from './user.js' +import type { HasOne } from '@adonisjs/lucid/types/relations' +import { json } from '#lib/lucid/decorators.js' + +export default class ParticipantProfile extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @hasOne(() => User) + declare user: HasOne + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + // General Info + + @column() + declare firstName: string + + @column() + declare lastName: string + + @column.date() + declare dateOfBirth: DateTime + + @column() + declare phone: string + + // Student Info + + @column() + declare university: string + + @column() + declare course: string + + @column() + declare curricularYear: string + + @column() + declare finishedAt: number | null + + @column() + declare municipality: string + + // Logistics Info + + @column() + declare shirtSize: string + + @column() + declare dietaryRestrictions: string | null + + @column() + declare isVegetarian: boolean + + @column() + declare isVegan: boolean + + @json() + declare transports: string[] + + // Communication Info + + @column() + declare heardAboutENEI: string + + @column() + declare reasonForSignup: string | null + + @json() + declare attendedBeforeEditions: string[] +} diff --git a/website/app/models/product.ts b/website/app/models/product.ts new file mode 100644 index 0000000..2993789 --- /dev/null +++ b/website/app/models/product.ts @@ -0,0 +1,37 @@ +import { DateTime } from 'luxon' +import { BaseModel, column } from '@adonisjs/lucid/orm' + +export default class Product extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @column() + declare description: string + + @column() + declare price: number + + @column() + declare stock: number + + @column() + declare currency: string + + @column() + declare max_order: number + + @column() + declare image: string + + @column() + declare productGroupId: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/website/app/models/product_group.ts b/website/app/models/product_group.ts new file mode 100644 index 0000000..46c6d97 --- /dev/null +++ b/website/app/models/product_group.ts @@ -0,0 +1,19 @@ +import { DateTime } from 'luxon' +import { BaseModel, column} from '@adonisjs/lucid/orm' + +export default class ProductGroup extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @column() + declare maxAmountPerGroup: number + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/website/app/models/user.ts b/website/app/models/user.ts index dfe4857..49e82c8 100644 --- a/website/app/models/user.ts +++ b/website/app/models/user.ts @@ -1,30 +1,39 @@ import { DateTime } from 'luxon' -import hash from '@adonisjs/core/services/hash' -import { compose } from '@adonisjs/core/helpers' -import { BaseModel, column } from '@adonisjs/lucid/orm' -import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' +import { BaseModel, belongsTo, column, hasMany } from '@adonisjs/lucid/orm' +import ParticipantProfile from './participant_profile.js' +import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' +import Account from './account.js' -const AuthFinder = withAuthFinder(() => hash.use('scrypt'), { - uids: ['email'], - passwordColumnName: 'password', -}) - -export default class User extends compose(BaseModel, AuthFinder) { +export default class User extends BaseModel { @column({ isPrimary: true }) declare id: number - @column() - declare fullName: string | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime @column() declare email: string - @column({ serializeAs: null }) - declare password: string + @column.dateTime() + declare emailVerifiedAt: DateTime | null - @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime + @hasMany(() => Account) + declare accounts: HasMany - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime | null + // Profiles + + @column() + declare participantProfileId: number | null + + @belongsTo(() => ParticipantProfile) + declare participantProfile: BelongsTo + + // Functions + + isEmailVerified() { + return this.emailVerifiedAt !== null + } } diff --git a/website/app/services/user_service.ts b/website/app/services/user_service.ts new file mode 100644 index 0000000..008b223 --- /dev/null +++ b/website/app/services/user_service.ts @@ -0,0 +1,74 @@ +import UserCreated from '#events/user_created' +import UserEmailVerified from '#events/user_email_verified' +import UserForgotPassword from '#events/user_forgot_password' +import SendForgotPasswordEmail from '#listeners/send_forgot_password_email' +import SendVerificationEmail from '#listeners/send_verification_email' +import User from '#models/user' +import db from '@adonisjs/lucid/services/db' +import { DateTime } from 'luxon' +import Account from '#models/account' +import { errors } from '@adonisjs/auth' +import app from '@adonisjs/core/services/app' +import { inject } from '@adonisjs/core' +import { Logger } from '@adonisjs/core/logger' + +@inject() +export class UserService { + constructor(private logger: Logger) {} + + async getUserWithCredentials(email: string, password: string) { + try { + const account = await Account.verifyCredentials(`credentials:${email}`, password) + await account.load('user') + + return account.user + } catch (error) { + if (error instanceof errors.E_INVALID_CREDENTIALS) { + return null + } + + throw error + } + } + + async createUserWithCredentials(email: string, password: string) { + const committedUser = await db.transaction(async (trx) => { + const user = await User.create({ email }, { client: trx }) + await user.related('accounts').create({ id: `credentials:${email}`, password }) + + return user + }) + + return [ + committedUser, + UserCreated.tryDispatch(committedUser), + ] as const + } + + async sendVerificationEmail(user: User) { + const listener = await app.container.make(SendVerificationEmail) + listener.handle(new UserCreated(user)) + .catch((error) => this.logger.error(error)) + } + + async verifyEmail(email: string) { + const verifiedUser = await db.transaction(async (trx) => { + const user = await User.findByOrFail('email', email, { client: trx }) + if (user.isEmailVerified()) return null + + user.emailVerifiedAt = DateTime.now() + return await user.save() + }) + + if (!verifiedUser) return null + + UserEmailVerified.tryDispatch(verifiedUser) + + return verifiedUser + } + + async sendForgotPasswordEmail(email: string) { + const listener = new SendForgotPasswordEmail() + listener.handle(new UserForgotPassword(email)) + } +} diff --git a/website/app/url.ts b/website/app/url.ts new file mode 100644 index 0000000..989aba6 --- /dev/null +++ b/website/app/url.ts @@ -0,0 +1,12 @@ +import env from '#start/env' +import router from '@adonisjs/core/services/router' + +const base = env.get('INERTIA_PUBLIC_APP_URL') + +export function staticUrl(path: string) { + return new URL(path, base).toString() +} + +export function buildUrl() { + return router.builder().prefixUrl(base) +} diff --git a/website/app/validators/account.ts b/website/app/validators/account.ts new file mode 100644 index 0000000..226f346 --- /dev/null +++ b/website/app/validators/account.ts @@ -0,0 +1,13 @@ +import vine from '@vinejs/vine' + +vine.convertEmptyStringsToNull = true + +export const socialAccountLoginValidator = vine.compile( + vine.object({ + id: vine + .string() + .parse((value) => + typeof value === 'string' ? value : typeof value === 'number' ? value.toString() : null + ), + }) +) diff --git a/website/app/validators/authentication.ts b/website/app/validators/authentication.ts new file mode 100644 index 0000000..ed7e4d8 --- /dev/null +++ b/website/app/validators/authentication.ts @@ -0,0 +1,35 @@ +import vine from '@vinejs/vine' +import User from '#models/user' + +export const registerWithCredentialsValidator = vine.compile( + vine.object({ + email: vine.string().email().unique({ table: User.table, column: 'email' }), + password: vine.string().minLength(8).confirmed(), + }) +) + +export const passwordResetValidator = vine.compile( + vine.object({ + email: vine.string().email().exists({ table: User.table, column: 'email' }), + password: vine.string().minLength(8).confirmed(), + }) +) + +export const passwordSendForgotPasswordValidator = vine.compile( + vine.object({ + email: vine.string().email() + }) +) + +export const emailVerificationCallbackValidator = vine.compile( + vine.object({ + email: vine.string().email().exists({ table: User.table, column: 'email' }), + }) +) + +export const loginWithCredentialsValidator = vine.compile( + vine.object({ + email: vine.string().email(), + password: vine.string(), + }) +) diff --git a/website/app/validators/order.ts b/website/app/validators/order.ts new file mode 100644 index 0000000..175f7d0 --- /dev/null +++ b/website/app/validators/order.ts @@ -0,0 +1,16 @@ +import vine from '@vinejs/vine' + +export const createMBWayOrderValidator = vine.compile( + vine.object({ + userId: vine.number(), + products: vine.array( + vine.object({ + productId: vine.number(), + quantity: vine.number(), + }) + ), + nif: vine.string().optional(), + address: vine.string().optional(), + mobileNumber: vine.string(), + }) +) diff --git a/website/app/validators/profile.ts b/website/app/validators/profile.ts new file mode 100644 index 0000000..db9b1a1 --- /dev/null +++ b/website/app/validators/profile.ts @@ -0,0 +1,34 @@ +import vine from '@vinejs/vine' +import universities from '#data/enei/universities.json' with { type: 'json' } +import districts from '#data/enei/districts.json' with { type: 'json' } +import editions from '#data/enei/editions.json' with { type: 'json' } +import transports from '#data/enei/signup/transports.json' with { type: 'json' } +import shirts from '#data/enei/signup/shirts.json' with { type: 'json' } +import heardaboutfrom from '#data/enei/signup/heard-about.json' with { type: 'json' } +import { DateTime } from 'luxon' + +export const createProfileValidator = vine.compile( + vine.object({ + firstName: vine.string(), + lastName: vine.string(), + dateOfBirth: vine + .date({ formats: { utc: true } }) + .transform((date) => DateTime.fromJSDate(date)), + phone: vine + .string() + .mobile(), + university: vine.string().in(universities.map((val) => val.id)), + course: vine.string(), + curricularYear: vine.string().in(['1', '2', '3', '4', '5', 'already-finished']), + finishedAt: vine.number().range([1930, new Date().getFullYear()]).nullable(), + municipality: vine.string().in(districts.map((dist) => dist.id)), + shirtSize: vine.string().in(shirts), + dietaryRestrictions: vine.string().trim().escape().nullable(), + isVegetarian: vine.boolean(), + isVegan: vine.boolean(), + transports: vine.array(vine.string().in(transports.map((item) => item.id))), + heardAboutENEI: vine.string().in(heardaboutfrom.map((item) => item.value)), + reasonForSignup: vine.string().nullable(), + attendedBeforeEditions: vine.array(vine.string().in(editions.map((item) => item.year.toString()))), + }) +) diff --git a/website/config/ally.ts b/website/config/ally.ts new file mode 100644 index 0000000..d49d03c --- /dev/null +++ b/website/config/ally.ts @@ -0,0 +1,28 @@ +import env from '#start/env' +import { defineConfig, services } from '@adonisjs/ally' + +const allyConfig = defineConfig({ + github: services.github({ + clientId: env.get('GITHUB_CLIENT_ID'), + clientSecret: env.get('GITHUB_CLIENT_SECRET'), + callbackUrl: '', + scopes: ['user:email'], + allowSignup: false, + }), + google: services.google({ + clientId: env.get('GOOGLE_CLIENT_ID'), + clientSecret: env.get('GOOGLE_CLIENT_SECRET'), + callbackUrl: '', + }), + linkedin: services.linkedin({ + clientId: env.get('LINKEDIN_CLIENT_ID'), + clientSecret: env.get('LINKEDIN_CLIENT_SECRET'), + callbackUrl: '', + }), +}) + +export default allyConfig + +declare module '@adonisjs/ally/types' { + interface SocialProviders extends InferSocialProviders {} +} diff --git a/website/config/database.ts b/website/config/database.ts index 4281077..3e52eb1 100644 --- a/website/config/database.ts +++ b/website/config/database.ts @@ -1,8 +1,11 @@ +import env from '#start/env' import app from '@adonisjs/core/services/app' import { defineConfig } from '@adonisjs/lucid' const dbConfig = defineConfig({ - connection: 'sqlite', + prettyPrintDebugQueries: true, + + connection: env.get('DB_CONNECTION', 'sqlite'), connections: { sqlite: { client: 'better-sqlite3', @@ -13,8 +16,18 @@ const dbConfig = defineConfig({ migrations: { naturalSort: true, paths: ['database/migrations'], - }, + }, }, + postgres: { + client: 'pg', + connection: { + host: env.get('POSTGRES_HOST', 'localhost'), + port: Number.parseInt(env.get('POSTGRES_PORT', '5432')), + user: env.get('POSTGRES_USER', 'postgres'), + password: env.get('POSTGRES_PASSWORD'), + database: env.get('POSTGRES_DB', 'postgres'), + }, + } }, }) diff --git a/website/config/inertia.ts b/website/config/inertia.ts index 668ebb3..1a1c084 100644 --- a/website/config/inertia.ts +++ b/website/config/inertia.ts @@ -1,7 +1,13 @@ +import type User from '#models/user' import env from '#start/env' import { defineConfig } from '@adonisjs/inertia' import type { InferSharedProps } from '@adonisjs/inertia/types' +export type AuthenticationData = + | { state: 'disabled' } + | { state: 'unauthenticated' } + | { state: 'authenticated'; user: Pick } + const inertiaConfig = defineConfig({ /** * Path to the Edge view that will be used as the root view for Inertia responses @@ -12,8 +18,16 @@ const inertiaConfig = defineConfig({ * Data that should be shared with all rendered pages */ sharedData: { - errors: (ctx) => ctx.session?.flashMessages.get('errors'), environment: env.public(), + auth: async ({ auth }): Promise => { + if (env.get('FEATURES_DISABLE_AUTH')) return { state: 'disabled' } + + if (!auth.authenticationAttempted) await auth.check() + const user = auth.user + + if (!user) return { state: 'unauthenticated' } + return { state: 'authenticated', user: { email: user.email } } + }, }, /** diff --git a/website/config/jobs.ts b/website/config/jobs.ts new file mode 100644 index 0000000..df42ca2 --- /dev/null +++ b/website/config/jobs.ts @@ -0,0 +1,50 @@ +import env from '#start/env' +import { defineConfig } from 'adonisjs-jobs' + +const jobsConfig = defineConfig({ + connection: { + host: env.get('REDIS_HOST', 'localhost'), + port: env.get('REDIS_PORT', 6379), + password: env.get('REDIS_PASSWORD'), + family: 0, + }, + + queue: env.get('REDIS_QUEUE', 'default'), + + queues: ['default'], + + options: { + /** + * The total number of attempts to try the job until it completes. + */ + attempts: 0, + + /** + * Backoff setting for automatic retries if the job fails + */ + backoff: { + type: 'exponential', + delay: 5000, + }, + + /** + * If true, removes the job when it successfully completes + * When given a number, it specifies the maximum amount of + * jobs to keep, or you can provide an object specifying max + * age and/or count to keep. It overrides whatever setting is used in the worker. + * Default behavior is to keep the job in the completed set. + */ + removeOnComplete: 1000, + + /** + * If true, removes the job when it fails after all attempts. + * When given a number, it specifies the maximum amount of + * jobs to keep, or you can provide an object specifying max + * age and/or count to keep. It overrides whatever setting is used in the worker. + * Default behavior is to keep the job in the failed set. + */ + removeOnFail: 1000, + }, +}) + +export default jobsConfig diff --git a/website/config/limiter.ts b/website/config/limiter.ts new file mode 100644 index 0000000..f070691 --- /dev/null +++ b/website/config/limiter.ts @@ -0,0 +1,31 @@ +import env from '#start/env' +import { defineConfig, stores } from '@adonisjs/limiter' + +const limiterConfig = defineConfig({ + default: env.get('LIMITER_STORE'), + + stores: { + /** + * Redis store to save rate limiting data inside a + * redis database. + * + * It is recommended to use a separate database for + * the limiter connection. + */ + redis: stores.redis({ + connectionName: 'limiter', + }), + + /** + * Memory store could be used during + * testing + */ + memory: stores.memory({}), + }, +}) + +export default limiterConfig + +declare module '@adonisjs/limiter/types' { + export interface LimitersList extends InferLimiters {} +} diff --git a/website/config/logger.ts b/website/config/logger.ts index b961300..e8ccc05 100644 --- a/website/config/logger.ts +++ b/website/config/logger.ts @@ -14,8 +14,13 @@ const loggerConfig = defineConfig({ enabled: true, name: env.get('APP_NAME'), level: env.get('LOG_LEVEL'), + + formatters: { + level: (label) => ({ level: label }), + }, + transport: { - targets: targets() + pipeline: targets() .pushIf(!app.inProduction, targets.pretty()) .pushIf(app.inProduction, targets.file({ destination: 1 })) .toArray(), diff --git a/website/config/mail.ts b/website/config/mail.ts index d6aa78a..f6ee3f2 100644 --- a/website/config/mail.ts +++ b/website/config/mail.ts @@ -4,6 +4,9 @@ import { defineConfig, transports } from '@adonisjs/mail' const mailConfig = defineConfig({ default: 'smtp', + from: env.get('FROM_EMAIL'), + replyTo: env.get('REPLY_TO_EMAIL'), + /** * The mailers object can be used to configure multiple mailers * each using a different transport or same transport with different @@ -17,11 +20,11 @@ const mailConfig = defineConfig({ * Uncomment the auth block if your SMTP * server needs authentication */ - /* auth: { + auth: { type: 'login', - user: env.get('SMTP_USERNAME'), - pass: env.get('SMTP_PASSWORD'), - }, */ + user: env.get('SMTP_USERNAME')!, + pass: env.get('SMTP_PASSWORD')!, + }, }), /*ses: transports.ses({ diff --git a/website/config/redis.ts b/website/config/redis.ts new file mode 100644 index 0000000..69080e9 --- /dev/null +++ b/website/config/redis.ts @@ -0,0 +1,48 @@ +import env from '#start/env' +import { defineConfig } from '@adonisjs/redis' +import type { InferConnections } from '@adonisjs/redis/types' + +const redisConfig = defineConfig({ + connection: 'main', + + connections: { + /* + |-------------------------------------------------------------------------- + | The default connection + |-------------------------------------------------------------------------- + | + | The main connection you want to use to execute redis commands. The same + | connection will be used by the session provider, if you rely on the + | redis driver. + | + */ + main: { + host: env.get('REDIS_HOST'), + port: env.get('REDIS_PORT'), + password: env.get('REDIS_PASSWORD', ''), + db: 0, + family: 0, + keyPrefix: '', + retryStrategy(times) { + return times > 10 ? null : times * 50 + }, + }, + + limiter: { + host: env.get('REDIS_HOST'), + port: env.get('REDIS_PORT'), + password: env.get('REDIS_PASSWORD', ''), + db: 1, + family: 0, + retryStrategy(times) { + return times > 10 ? null : times * 50 + }, + }, + }, +}) + +export default redisConfig + +declare module '@adonisjs/redis/types' { + export interface RedisConnections extends InferConnections {} +} diff --git a/website/data/enei/districts.json b/website/data/enei/districts.json new file mode 100644 index 0000000..f1802fa --- /dev/null +++ b/website/data/enei/districts.json @@ -0,0 +1,23 @@ +[ + { "id": "aveiro", "name": "Aveiro" }, + { "id": "beja", "name": "Beja" }, + { "id": "braga", "name": "Braga" }, + { "id": "braganca", "name": "Bragança" }, + { "id": "castelo-branco", "name": "Castelo Branco" }, + { "id": "coimbra", "name": "Coimbra" }, + { "id": "evora", "name": "Évora" }, + { "id": "faro", "name": "Faro" }, + { "id": "guarda", "name": "Guarda" }, + { "id": "leiria", "name": "Leiria" }, + { "id": "lisboa", "name": "Lisboa" }, + { "id": "portalegre", "name": "Portalegre" }, + { "id": "porto", "name": "Porto" }, + { "id": "santarem", "name": "Santarém" }, + { "id": "setubal", "name": "Setúbal" }, + { "id": "viana-do-castelo", "name": "Viana do Castelo" }, + { "id": "vila-real", "name": "Vila Real" }, + { "id": "viseu", "name": "Viseu" }, + { "id": "acores", "name": "Açores" }, + { "id": "madeira", "name": "Madeira" }, + { "id": "estrangeiro", "name": "Fora de Portugal" } +] diff --git a/website/data/enei/editions.json b/website/data/enei/editions.json new file mode 100644 index 0000000..ed0fc2f --- /dev/null +++ b/website/data/enei/editions.json @@ -0,0 +1,62 @@ +[ + { + "year": 2005, + "location": "Coimbra" + }, + { + "year": 2006, + "location": "Évora" + }, + { + "year": 2007, + "location": "Guarda" + }, + { + "year": 2008, + "location": "Aveiro" + }, + { + "year": 2010, + "location": "Coimbra" + }, + { + "year": 2011, + "location": "Vila Real" + }, + { + "year": 2012, + "location": "Lisboa" + }, + { + "year": 2013, + "location": "Porto" + }, + { + "year": 2014, + "location": "Aveiro" + }, + { + "year": 2015, + "location": "Coimbra" + }, + { + "year": 2016, + "location": "Aveiro" + }, + { + "year": 2018, + "location": "Porto" + }, + { + "year": 2019, + "location": "Coimbra" + }, + { + "year": 2020, + "location": "Braga" + }, + { + "year": 2023, + "location": "Aveiro" + } +] diff --git a/website/data/enei/signup/heard-about.json b/website/data/enei/signup/heard-about.json new file mode 100644 index 0000000..60a62d8 --- /dev/null +++ b/website/data/enei/signup/heard-about.json @@ -0,0 +1,22 @@ +[ + { + "value":"friends", + "label":"Amigos" + }, + { + "value":"social-media", + "label":"Redes Sociais" + }, + { + "value":"university", + "label":"Banca" + }, + { + "value":"other", + "label":"Outro" + }, + { + "value":"unknown", + "label":"Já não me lembro" + } +] diff --git a/website/data/enei/signup/shirts.json b/website/data/enei/signup/shirts.json new file mode 100644 index 0000000..31f3534 --- /dev/null +++ b/website/data/enei/signup/shirts.json @@ -0,0 +1 @@ +["XS", "S", "M", "L", "XL", "2XL", "3XL"] diff --git a/website/data/enei/signup/transports.json b/website/data/enei/signup/transports.json new file mode 100644 index 0000000..bc4f02c --- /dev/null +++ b/website/data/enei/signup/transports.json @@ -0,0 +1,50 @@ +[ + { + "id": "cp", + "description": "CP - Comboios de Portugal" + }, + { + "id": "metro-do-porto", + "description": "Metro do Porto" + }, + { + "id": "stcp", + "description": "STCP" + }, + { + "id": "rede-expressos", + "description": "Rede Expressos" + }, + { + "id": "flix-bus", + "description": "FlixBus" + }, + { + "id": "aviao", + "description": "Avião" + }, + { + "id": "carro", + "description": "Carro" + }, + { + "id": "tvde", + "description": "TVDE (Uber, Bolt, ...)" + }, + { + "id": "taxi", + "description": "Táxi" + }, + { + "id": "bicicleta", + "description": "Bicicleta" + }, + { + "id": "trotinete", + "description": "Trotinete" + }, + { + "id": "a-pe", + "description": "A pé" + } +] diff --git a/website/data/enei/universities.json b/website/data/enei/universities.json new file mode 100644 index 0000000..41d880c --- /dev/null +++ b/website/data/enei/universities.json @@ -0,0 +1,168 @@ +[ + { "id": "pt.enautica", "name": "Escola Superior Náutica Infante D. Henrique" }, + { + "id": "pt.politecnicoguarda", + "name": "Escola Superior de Tecnologia e Gestão - Instituto Politécnico da Guarda" + }, + { + "id": "pt.ipbeja", + "name": "Escola Superior de Tecnologia e de Gestão - Instituto Politécnico de Beja" + }, + { + "id": "pt.ipb", + "name": "Escola Superior de Tecnologia e de Gestão de Bragança - Instituto Politécnico de Bragança" + }, + { + "id": "pt.ipcb", + "name": "Escola Superior de Tecnologia de Castelo Branco - Instituto Politécnico de Castelo Branco" + }, + { + "id": "pt.ipc.estgoh", + "name": "Escola Superior de Tecnologia e Gestão de Oliveira do Hospital - Instituto Politécnico de Coimbra" + }, + { + "id": "pt.isec", + "name": "Instituto Superior de Engenharia de Coimbra - Instituto Politécnico de Coimbra" + }, + { + "id": "pt.ipleiria", + "name": "Escola Superior de Tecnologia e Gestão - Instituto Politécnico de Leiria" + }, + { + "id": "pt.isel", + "name": "Instituto Superior de Engenharia de Lisboa - Instituto Politécnico de Lisboa" + }, + { + "id": "pt.ipportalegre", + "name": "Escola Superior de Tecnologia, Gestão e Design - Instituto Politécnico de Portalegre" + }, + { + "id": "pt.ipsantarem", + "name": "Escola Superior de Gestão e Tecnologia de Santarém - Instituto Politécnico de Santarém" + }, + { + "id": "pt.ips.estbarreiro", + "name": "Escola Superior de Tecnologia do Barreiro - Instituto Politécnico de Setúbal" + }, + { + "id": "pt.ips.estsetubal", + "name": "Escola Superior de Tecnologia de Setúbal - Instituto Politécnico de Setúbal" + }, + { + "id": "pt.ipt.esta", + "name": "Escola Superior de Tecnologia de Abrantes - Instituto Politécnico de Tomar" + }, + { + "id": "pt.ipt.estt", + "name": "Escola Superior de Tecnologia de Tomar - Instituto Politécnico de Tomar" + }, + { + "id": "pt.ipvc", + "name": "Escola Superior de Tecnologia e Gestão - Instituto Politécnico de Viana do Castelo" + }, + { + "id": "pt.ipv.estgl", + "name": "Escola Superior de Tecnologia e Gestão de Lamego - Instituto Politécnico de Viseu" + }, + { + "id": "pt.ipv.estgv", + "name": "Escola Superior de Tecnologia e Gestão de Viseu - Instituto Politécnico de Viseu" + }, + { + "id": "pt.ipca.est", + "name": "Escola Superior de Tecnologia - Instituto Politécnico do Cávado e do Ave" + }, + { + "id": "pt.ipca.etesp", + "name": "Escola Técnica Superior Profissional - Instituto Politécnico do Cávado e do Ave" + }, + { + "id": "pt.ipp.estg", + "name": "Escola Superior de Tecnologia e Gestão - Instituto Politécnico do Porto" + }, + { + "id": "pt.ipp.isep", + "name": "Instituto Superior de Engenharia do Porto - Instituto Politécnico do Porto" + }, + { "id": "pt.iscte-iul", "name": "ISCTE - Instituto Universitário de Lisboa" }, + { "id": "pt.uab", "name": "Universidade Aberta" }, + { "id": "pt.ubi", "name": "Universidade da Beira Interior" }, + { + "id": "pt.uma", + "name": "Faculdade de Ciências Exatas e da Engenharia - Universidade da Madeira" + }, + { + "id": "pt.ua", + "name": "Universidade de Aveiro" + }, + { + "id": "pt.ua/estga", + "name": "Escola Superior de Tecnologia e Gestão de Águeda - Universidade de Aveiro" + }, + { "id": "pt.uc/fctuc", "name": "Faculdade de Ciências e Tecnologia - Universidade de Coimbra" }, + { "id": "pt.ulisboa.tecnico", "name": "Instituto Superior Técnico - Universidade de Lisboa" }, + { + "id": "pt.ulisboa.tecnico/taguspark", + "name": "Instituto Superior Técnico (Tagus Park) - Universidade de Lisboa" + }, + { + "id": "pt.utad/ect", + "name": "Escola de Ciências e Tecnologia - Universidade de Trás-os-Montes e Alto Douro" + }, + { "id": "pt.uevora.ect", "name": "Escola de Ciências e Tecnologia - Universidade de Évora" }, + { "id": "pt.ualg.fct", "name": "Faculdade de Ciências e Tecnologia - Universidade do Algarve" }, + { "id": "pt.ualg.ise", "name": "Instituto Superior de Engenharia - Universidade do Algarve" }, + { "id": "pt.uminho", "name": "Universidade do Minho" }, + { "id": "pt.up.fe", "name": "Faculdade de Engenharia - Universidade do Porto" }, + { "id": "pt.up.fc", "name": "Faculdade de Ciências - Universidade do Porto" }, + { + "id": "pt.uac.esta", + "name": "Escola Superior de Tecnologias e Administração - Universidade dos Açores" + }, + { "id": "pt.uac.fct", "name": "Faculdade de Ciências e Tecnologia - Universidade dos Açores" }, + { + "id": "pt.unl.fct", + "name": "Faculdade de Ciências e Tecnologia - Universidade Nova de Lisboa" + }, + { + "id": "pt.unl.novaims", + "name": "NOVA Information Management School - Universidade Nova de Lisboa" + }, + { "id": "pt.uatlantica", "name": "Atlântica - Instituto Universitário" }, + { "id": "pt.umaia", "name": "Universidade da Maia" }, + { "id": "pt.europeia", "name": "Universidade Europeia" }, + { "id": "pt.ufp", "name": "Universidade Fernando Pessoa" }, + { "id": "pt.ulusiada.lis", "name": "Centro Universitário de Lisboa - Universidade Lusíada" }, + { + "id": "pt.ulusiada.fam", + "name": "Centro Universitário do Norte (campus de Vila Nova de Famalicão) - Universidade Lusíada" + }, + { "id": "pt.ulusofona", "name": "Universidade Lusófona" }, + { "id": "pt.ulusofona/cul", "name": "Centro Universitário de Lisboa - Universidade Lusófona" }, + { "id": "pt.ulusofona/cup", "name": "Centro Universitário do Porto - Universidade Lusófona" }, + { "id": "pt.upt", "name": "Universidade Portucalense Infante D. Henrique" }, + { "id": "pt.iesfafe", "name": "Escola Superior de Tecnologias de Fafe" }, + { + "id": "pt.ipluso", + "name": "Escola Superior de Engenharia e Tecnologias - Instituto Politécnico da Lusofonia" + }, + { + "id": "pt.ipmaia/estg", + "name": "Escola Superior de Tecnologia e Gestão - Instituto Politécnico da Maia" + }, + { "id": "pt.ipmaia", "name": "Instituto Politécnico da Maia" }, + { + "id": "org.ipiaget/estgjpalmada", + "name": "Escola Superior de Tecnologia e Gestão Jean Piaget - Instituto Politécnico Jean Piaget do Sul" + }, + { "id": "pt.istec", "name": "Instituto Superior de Tecnologias Avançadas de Lisboa" }, + { "id": "pt.istec-porto", "name": "Instituto Superior de Tecnologias Avançadas do Porto" }, + { "id": "pt.ismat", "name": "Instituto Superior Manuel Teixeira Gomes" }, + { "id": "pt.ismt", "name": "Instituto Superior Miguel Torga" }, + { + "id": "pt.ispgaya", + "name": "Escola Superior de Ciência e Tecnologia - Instituto Superior Politécnico Gaya" + }, + { "id": "pt.islagaia", "name": "ISLA - Instituto Politécnico de Gestão e Tecnologia" }, + { "id": "pt.autonoma", "name": "Universidade Autónoma de Lisboa Luís de Camões" } +] diff --git a/website/inertia/data/countries.json b/website/data/location-input/countries.json similarity index 100% rename from website/inertia/data/countries.json rename to website/data/location-input/countries.json diff --git a/website/inertia/data/states.json b/website/data/location-input/states.json similarity index 100% rename from website/inertia/data/states.json rename to website/data/location-input/states.json diff --git a/website/database/migrations/1734342326224_create_users_table.ts b/website/database/migrations/1734342326224_create_users_table.ts deleted file mode 100644 index dbca083..0000000 --- a/website/database/migrations/1734342326224_create_users_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BaseSchema } from '@adonisjs/lucid/schema' - -export default class extends BaseSchema { - protected tableName = 'users' - - async up() { - this.schema.createTable(this.tableName, (table) => { - table.increments('id').notNullable() - table.string('full_name').nullable() - table.string('email', 254).notNullable().unique() - table.string('password').notNullable() - - table.timestamp('created_at').notNullable() - table.timestamp('updated_at').nullable() - }) - } - - async down() { - this.schema.dropTable(this.tableName) - } -} diff --git a/website/database/migrations/1734776375676_create_create_product_groups_table.ts b/website/database/migrations/1734776375676_create_create_product_groups_table.ts new file mode 100644 index 0000000..281a410 --- /dev/null +++ b/website/database/migrations/1734776375676_create_create_product_groups_table.ts @@ -0,0 +1,19 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'product_groups' + + public async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + table.string('name').notNullable().unique() + table.integer('max_amount_per_group').notNullable() + table.timestamp('created_at', { useTz: true }).defaultTo(this.now()) + table.timestamp('updated_at', { useTz: true }).defaultTo(this.now()) + }) + } + + public async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/website/database/migrations/1734798835308_create_tickets_table.ts b/website/database/migrations/1734776385300_create_orders_table.ts similarity index 57% rename from website/database/migrations/1734798835308_create_tickets_table.ts rename to website/database/migrations/1734776385300_create_orders_table.ts index f6ddda6..085946a 100644 --- a/website/database/migrations/1734798835308_create_tickets_table.ts +++ b/website/database/migrations/1734776385300_create_orders_table.ts @@ -1,15 +1,18 @@ import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { - protected tableName = 'tickets' + protected tableName = 'orders' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') - table.string('name').nullable() - table.text('description') - table.float('price').notNullable() - table.integer('stock').notNullable() + table.integer('request_id') + table.string('status') + table.integer('user_id').notNullable() + table.string('name') + table.integer('nif') + table.string('address') + table.float('total') table.timestamp('created_at') table.timestamp('updated_at') }) @@ -18,4 +21,4 @@ export default class extends BaseSchema { async down() { this.schema.dropTable(this.tableName) } -} +} \ No newline at end of file diff --git a/website/database/migrations/1734977097919_create_products_table.ts b/website/database/migrations/1734977097919_create_products_table.ts new file mode 100644 index 0000000..cfa141c --- /dev/null +++ b/website/database/migrations/1734977097919_create_products_table.ts @@ -0,0 +1,26 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'products' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('name').notNullable() + table.text('description').notNullable() + table.float('price').notNullable() + table.integer('stock').notNullable() + table.integer('max_order').notNullable() + table.string('currency').notNullable() + table.string('image').notNullable + table.integer('product_group_id').unsigned().references('id').inTable('product_groups').onDelete('CASCADE') + + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/website/database/migrations/1738369888855_create_order_products_table.ts b/website/database/migrations/1738369888855_create_order_products_table.ts new file mode 100644 index 0000000..d84cb8f --- /dev/null +++ b/website/database/migrations/1738369888855_create_order_products_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'order_products' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.integer('order_id').unsigned().references('id').inTable('orders').onDelete('CASCADE') + table + .integer('product_id') + .unsigned() + .references('id') + .inTable('products') + .onDelete('CASCADE') + table.integer('quantity').notNullable() + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/website/database/migrations/1738945153275_create_users_table.ts b/website/database/migrations/1738945153275_create_users_table.ts new file mode 100644 index 0000000..7bbe424 --- /dev/null +++ b/website/database/migrations/1738945153275_create_users_table.ts @@ -0,0 +1,19 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'users' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.timestamps({ defaultToNow: true }) + + table.string('email').unique().notNullable() + table.timestamp('email_verified_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} \ No newline at end of file diff --git a/website/database/migrations/1738945156003_create_accounts_table.ts b/website/database/migrations/1738945156003_create_accounts_table.ts new file mode 100644 index 0000000..29aefcf --- /dev/null +++ b/website/database/migrations/1738945156003_create_accounts_table.ts @@ -0,0 +1,20 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'accounts' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.string('id').primary() + table.timestamps({ defaultToNow: true }) + + table.string('password') + + table.integer('user_id').references('id').inTable('users').notNullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} \ No newline at end of file diff --git a/website/database/migrations/1738945161326_create_participant_profiles_table.ts b/website/database/migrations/1738945161326_create_participant_profiles_table.ts new file mode 100644 index 0000000..fa2861e --- /dev/null +++ b/website/database/migrations/1738945161326_create_participant_profiles_table.ts @@ -0,0 +1,41 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'participant_profiles' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.timestamps({ defaultToNow: true }) + + // General Info + table.string('first_name').notNullable() + table.string('last_name').notNullable() + table.date('date_of_birth').notNullable() + table.string('phone').unique().notNullable() + + // Student Info + table.string('university').notNullable() + table.string('course').notNullable() + table.string('curricular_year').notNullable() + table.integer('finished_at').nullable() + table.string('municipality').notNullable() + + // Logistics Info + table.string('shirt_size').notNullable() + table.string('dietary_restrictions').nullable() + table.boolean('is_vegetarian').notNullable() + table.boolean('is_vegan').notNullable() + table.jsonb('transports').notNullable() + + // Communications Info + table.string('heard_about_enei').notNullable() + table.string('reason_for_signup').nullable() + table.jsonb('attended_before_editions').notNullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} \ No newline at end of file diff --git a/website/database/migrations/1738945446205_alter_users_table.ts b/website/database/migrations/1738945446205_alter_users_table.ts new file mode 100644 index 0000000..2086ca1 --- /dev/null +++ b/website/database/migrations/1738945446205_alter_users_table.ts @@ -0,0 +1,20 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'users' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.integer('participant_profile_id') + .unique() + .references('id') + .inTable('participant_profiles') + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('participant_profile_id') + }) + } +} \ No newline at end of file diff --git a/website/database/migrations/1739026343722_alter_orders_table.ts b/website/database/migrations/1739026343722_alter_orders_table.ts new file mode 100644 index 0000000..13329c7 --- /dev/null +++ b/website/database/migrations/1739026343722_alter_orders_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'orders' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('request_id') + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.integer('request_id') + }) + } +} \ No newline at end of file diff --git a/website/database/migrations/1739026559954_alter_orders_table.ts b/website/database/migrations/1739026559954_alter_orders_table.ts new file mode 100644 index 0000000..a03d16b --- /dev/null +++ b/website/database/migrations/1739026559954_alter_orders_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'orders' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.string('request_id') + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('request_id') + }) + } +} \ No newline at end of file diff --git a/website/database/migrations/1739145873655_alter_participant_profiles_table.ts b/website/database/migrations/1739145873655_alter_participant_profiles_table.ts new file mode 100644 index 0000000..9e25adc --- /dev/null +++ b/website/database/migrations/1739145873655_alter_participant_profiles_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'participant_profiles' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.dropUnique(['phone']) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.unique(['phone']) + }) + } +} \ No newline at end of file diff --git a/website/database/seeders/0_product_group_seeder.ts b/website/database/seeders/0_product_group_seeder.ts new file mode 100644 index 0000000..1d43392 --- /dev/null +++ b/website/database/seeders/0_product_group_seeder.ts @@ -0,0 +1,13 @@ + +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import ProductGroup from '#models/product_group' +export default class ProductGroupSeeder extends BaseSeeder { + public async run() { + await ProductGroup.createMany([ + { + name: 'Tickets', + maxAmountPerGroup: 1, + }, + ]) + } +} diff --git a/website/database/seeders/1_product_seeder.ts b/website/database/seeders/1_product_seeder.ts new file mode 100644 index 0000000..9ea4ef3 --- /dev/null +++ b/website/database/seeders/1_product_seeder.ts @@ -0,0 +1,28 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import Product from '#models/product' +export default class ProductSeeder extends BaseSeeder { + public async run() { + await Product.create({ + name: 'Bilhete Early Bird - Com Alojamento', + description: + 'Inclui:
• Pequenos-almoços, almoços e jantares durante o período do evento
• Acesso a coffee breaks e sessão de cocktails
• Acesso a workshops, palestras e outros
• Acesso a festas noturnas e outras atividades recreativas (exceto Rally Tascas)
• Alojamento em Pavilhão', + price: 35, + stock: 100, + currency: 'EUR', + max_order: 1, + productGroupId: 1, + image: '/favicon.svg', + }) + await Product.create({ + name: 'Bilhete Early Bird - Sem Alojamento', + description: + 'Inclui:
• Pequenos-almoços, almoços e jantares durante o período do evento
• Acesso a coffee breaks e sessão de cocktails
• Acesso a workshops, palestras e outros
• Acesso a festas noturnas e outras atividades recreativas (exceto Rally Tascas)', + price: 30, + stock: 50, + currency: 'EUR', + max_order: 1, + productGroupId: 1, + image: '/favicon.svg', + }) + } +} diff --git a/website/database/seeders/ticket_seeder.ts b/website/database/seeders/ticket_seeder.ts deleted file mode 100644 index 6e8e514..0000000 --- a/website/database/seeders/ticket_seeder.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BaseSeeder } from '@adonisjs/lucid/seeders' -import Ticket from '#models/ticket' - -export default class extends BaseSeeder { - async run() { - await Ticket.createMany([ - { - name: 'Bilhete - Com Alojamento', - description: - 'Inclui:\n• Pequenos-almoços, almoços e jantares durante o período do evento\n• Acesso a coffee breaks e sessão de cocktails\n• Acesso a workshops, palestras e outros\n• Acesso a festas noturnas e outras atividades recreativas (exceto Rally Tascas) \n• Alojamento em Pavilhão', - price: 35, - stock: 150, - }, - { - name: 'Bilhete - Sem Alojamento', - description: - 'Inclui:\n• Pequenos-almoços, almoços e jantares durante o período do evento\n• Acesso a coffee breaks e sessão de cocktails\n• Acesso a workshops, palestras e outros\n• Acesso a festas noturnas e outras atividades recreativas (exceto Rally Tascas)', - price: 30, - stock: 50, - }, - ]) - } -} diff --git a/website/ecosystem.config.cjs b/website/ecosystem.config.cjs index 3630640..373d96e 100644 --- a/website/ecosystem.config.cjs +++ b/website/ecosystem.config.cjs @@ -7,5 +7,11 @@ module.exports = { exec_mode: 'cluster', autorestart: true, }, + { + name: 'enei-jobs', + script: './ace.js', + args: 'jobs:listen', + autorestart: true, + }, ], } diff --git a/website/eslint.config.js b/website/eslint.config.js index 9be1be3..3e88f05 100644 --- a/website/eslint.config.js +++ b/website/eslint.config.js @@ -1,2 +1,19 @@ -import { configApp } from '@adonisjs/eslint-config' -export default configApp() +import { configApp, RULES_LIST } from '@adonisjs/eslint-config' + +// Downgrade all lints to warnings +import 'eslint-plugin-only-warn' + +export default configApp( + { + name: 'Custom config for Inertia', + files: ['inertia/**/*.ts', 'inertia/**/*.tsx'], + ignores: ['inertia/components/ui/**/*'], + rules: RULES_LIST, + }, + { + ignores: ['.adonisjs/**/*'], + rules: { + 'prettier/prettier': 'off', + }, + } +) diff --git a/website/inertia/app/app.tsx b/website/inertia/app/app.tsx index fe79063..d3c2065 100644 --- a/website/inertia/app/app.tsx +++ b/website/inertia/app/app.tsx @@ -1,13 +1,16 @@ /// -/// +/// /// +/// +/// +/// import '../css/app.css' import { resolvePageComponent } from '@adonisjs/inertia/helpers' import { createInertiaApp } from '@inertiajs/react' import { hydrateRoot } from 'react-dom/client' -import { TuyauWrapper } from './tuyau' +import { Providers } from './providers' const appName = import.meta.env.VITE_APP_NAME || 'ENEI' @@ -17,7 +20,10 @@ createInertiaApp({ title: (title) => `${title} - ${appName}`, resolve: (name) => { - return resolvePageComponent(`../pages/${name}.tsx`, import.meta.glob('../pages/**/*.tsx')) + return resolvePageComponent( + `../pages/${name}/page.tsx`, + import.meta.glob('../pages/**/page.tsx') + ) }, setup({ el, App, props }) { @@ -26,9 +32,9 @@ createInertiaApp({ <> {(page) => ( - + - + )} diff --git a/website/inertia/app/providers/index.tsx b/website/inertia/app/providers/index.tsx new file mode 100644 index 0000000..714cd22 --- /dev/null +++ b/website/inertia/app/providers/index.tsx @@ -0,0 +1,13 @@ +import { Provider as JotaiProvider } from 'jotai/react' +import { TuyauProvider } from './tuyau' +import { NotificationProvider } from '~/components/notifications' + +export function Providers({ children }: { children?: React.ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/website/inertia/app/providers/tuyau.tsx b/website/inertia/app/providers/tuyau.tsx new file mode 100644 index 0000000..1d88e47 --- /dev/null +++ b/website/inertia/app/providers/tuyau.tsx @@ -0,0 +1,22 @@ +import { api } from '#.adonisjs/api' +import { createTuyau } from '@tuyau/client' +import { TuyauProvider as $TuyauProvider } from '@tuyau/inertia/react' +import { useEnvironment } from '~/hooks/use_env' + +export type TuyauClient = ReturnType + +function useTuyau() { + const tuyau = useEnvironment((env) => + createTuyau({ + api, + baseUrl: env.INERTIA_PUBLIC_APP_URL, + }) + ) + + return tuyau +} + +export function TuyauProvider({ children }: { children?: React.ReactNode }) { + const tuyau = useTuyau() + return <$TuyauProvider client={tuyau}>{children} +} diff --git a/website/inertia/app/ssr.tsx b/website/inertia/app/ssr.tsx index 77a6e5e..f0d88bd 100644 --- a/website/inertia/app/ssr.tsx +++ b/website/inertia/app/ssr.tsx @@ -1,22 +1,22 @@ import ReactDOMServer from 'react-dom/server' import { createInertiaApp } from '@inertiajs/react' -import { TuyauWrapper } from './tuyau' +import { Providers } from './providers' -export default function render(page: any) { +export default function render(intialPage: any) { return createInertiaApp({ - page, + page: intialPage, render: ReactDOMServer.renderToString, resolve: (name) => { - const pages = import.meta.glob('../pages/**/*.tsx', { eager: true }) - return pages[`../pages/${name}.tsx`] + const pages = import.meta.glob('../pages/**/page.tsx', { eager: true }) + return pages[`../pages/${name}/page.tsx`] }, setup: ({ App, props }) => ( <> {(page) => ( - + - + )} diff --git a/website/inertia/app/tuyau.tsx b/website/inertia/app/tuyau.tsx deleted file mode 100644 index c54f6cd..0000000 --- a/website/inertia/app/tuyau.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { api } from '#.adonisjs/api' -import { createTuyau } from '@tuyau/client' -import { TuyauProvider } from '@tuyau/inertia/react' -import { useEnvironment } from '~/hooks/use_env' - -export function TuyauWrapper({ children }: { children?: React.ReactNode }) { - const tuyau = useEnvironment((env) => - createTuyau({ - api, - baseUrl: env.INERTIA_PUBLIC_APP_URL, - }) - ) - - return {children} -} diff --git a/website/inertia/components/common/containers/card.tsx b/website/inertia/components/common/containers/card.tsx new file mode 100644 index 0000000..9997e22 --- /dev/null +++ b/website/inertia/components/common/containers/card.tsx @@ -0,0 +1,6 @@ +import { cn } from '~/lib/utils'; +import Container from '.' + +export default function CardContainer({ children, className }: { className?: string; children?: React.ReactNode }) { + return {children} +} diff --git a/website/inertia/components/common/containers/index.tsx b/website/inertia/components/common/containers/index.tsx new file mode 100644 index 0000000..e99d385 --- /dev/null +++ b/website/inertia/components/common/containers/index.tsx @@ -0,0 +1,11 @@ +import { cn } from '~/lib/utils' + +export default function Container({ + className, + children, +}: { + className?: string + children?: React.ReactNode +}) { + return
{children}
+} diff --git a/website/inertia/components/common/hero.tsx b/website/inertia/components/common/hero.tsx new file mode 100644 index 0000000..305815a --- /dev/null +++ b/website/inertia/components/common/hero.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { cn } from '~/lib/utils' + +export default function Hero({ + className, + children, +}: { + className?: string + children?: React.ReactNode +}) { + return ( +
{children}
+ ) +} diff --git a/website/inertia/components/common/navbar.tsx b/website/inertia/components/common/navbar.tsx new file mode 100644 index 0000000..00674fb --- /dev/null +++ b/website/inertia/components/common/navbar.tsx @@ -0,0 +1,107 @@ +import { useForm } from '@inertiajs/react' +import { Link } from '@tuyau/inertia/react' +import { Button, buttonVariants } from '~/components/ui/button' +import { useAuth } from '~/hooks/use_auth' +import { useTuyau } from '~/hooks/use_tuyau' +import { cn } from '~/lib/utils' +import Container from './containers' +import { useEffect, useState } from 'react' +import { NotificationContainer } from '../notifications' + +/* +import { Menu } from "lucide-react"; +import { + NavigationMenu, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, +} from "./ui/navigation-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +import { Button } from "./ui/button"; + +type PageRoute = { + href: string; + title: string; +}; + +*/ + +function LoginButton() { + return ( + + Entrar + + ) +} + +function LogoutButton() { + const tuyau = useTuyau() + const { post } = useForm() + + function onSubmit(e: React.FormEvent) { + e.preventDefault() + post(tuyau.$url('actions:auth.logout')) + } + + return ( +
+ +
+ ) +} + +export function Navbar({ className }: { className?: string }) { + const auth = useAuth() + const [onTop, setOnTop] = useState(true) + + useEffect(() => { + const controller = new AbortController() + + window.addEventListener( + 'scroll', + () => { + setOnTop(window.scrollY === 0) + }, + { signal: controller.signal } + ) + + return () => controller.abort() + }, []) + + return ( + <> + + + + ) +} diff --git a/website/inertia/components/common/page.tsx b/website/inertia/components/common/page.tsx new file mode 100644 index 0000000..74d466d --- /dev/null +++ b/website/inertia/components/common/page.tsx @@ -0,0 +1,24 @@ +import { Head } from '@inertiajs/react' +import React from 'react' +import { cn } from '~/lib/utils' +import { Navbar } from './navbar' +import {Toaster} from '~/components/ui/toaster' +export default function Page({ + title, + className, + children, +}: { + title: string + className?: string + children?: React.ReactNode +}) { + return ( +
+ + + + + {children} +
+ ) +} diff --git a/website/inertia/components/navbar.tsx b/website/inertia/components/navbar.tsx deleted file mode 100644 index 4f5bc5c..0000000 --- a/website/inertia/components/navbar.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Link } from "@inertiajs/react"; - -/* -import { Menu } from "lucide-react"; -import { - NavigationMenu, - NavigationMenuItem, - NavigationMenuLink, - NavigationMenuList, -} from "./ui/navigation-menu"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; - -import { Button } from "./ui/button"; - -type PageRoute = { - href: string; - title: string; -}; - -*/ - -export default function NavBar() { - /* - const navButtonStyle = - "font-space-grotesk uppercase group inline-flex h-9 w-max items-center justify-center text-base font-bold text-enei-beige focus:outline-none disabled:pointer-events-none"; - const navLoginStyle = - "font-space-grotesk group inline-flex h-10 px-6 rounded-md w-max items-center justify-center text-base font-bold bg-enei-beige text-enei-blue focus:outline-none disabled:pointer-events-none"; - const routes: PageRoute[] = [{ - href: "/", - title: "Programa", - }, { - href: "/", - title: "Loja", - }, { - href: "/", - title: "Equipa", - }]; - */ - - return ( - <> - - - ); -} diff --git a/website/inertia/components/notifications.tsx b/website/inertia/components/notifications.tsx new file mode 100644 index 0000000..4a5b9a3 --- /dev/null +++ b/website/inertia/components/notifications.tsx @@ -0,0 +1,43 @@ +import { createContext, useCallback, ReactNode, Key, useContext, useState } from 'react' +import { createPortal } from 'react-dom' + +type NotificationRenderFunction = (children: ReactNode, key?: Key) => React.ReactPortal | undefined + +const NotificationContext = createContext<{ + setContainer: React.Dispatch> + render: NotificationRenderFunction +} | null>(null) + +export function NotificationProvider({ children }: { children: React.ReactNode }) { + const [container, setContainer] = useState(null) + + const render: NotificationRenderFunction = useCallback( + (notification, key) => { + if (!container) return + return createPortal(notification, container, key) + }, + [container] + ) + + return ( +
+ + {children} + +
+ ) +} + +export function NotificationContainer(props: { className?: string }) { + const context = useContext(NotificationContext) + if (!context) throw new Error('NotificationContainer must be used within a NotificationProvider') + + return
context.setContainer(el)} {...props} /> +} + +export function Notification({ children, key }: { children: React.ReactNode; key?: Key }) { + const context = useContext(NotificationContext) + if (!context) throw new Error('Notification must be used within a NotificationProvider') + + return context.render(
{children}
, key) +} diff --git a/website/inertia/components/payments/billing_information_form.tsx b/website/inertia/components/payments/billing_information_form.tsx new file mode 100644 index 0000000..bc6c647 --- /dev/null +++ b/website/inertia/components/payments/billing_information_form.tsx @@ -0,0 +1,73 @@ +import { Checkbox } from '~/components/ui/checkbox' +import { Label } from '~/components/ui/label' +import { Input } from '~/components/ui/input' + +interface BillingInformationFormProps { + enableBillingInfo: boolean + setEnableBillingInfo: (checked: boolean) => void + billingInfo: { + name: string + vat: string + address: string + } + onBillingInfoChange: (field: string, value: string) => void +} + +export default function BillingInformationForm({ + enableBillingInfo, + setEnableBillingInfo, + billingInfo, + onBillingInfoChange, +}: BillingInformationFormProps) { + const handleInputChange = (key: string) => (event: React.ChangeEvent) => { + onBillingInfoChange(key, event.target.value) + } + return ( +
+ {/* Checkbox */} +

2. Dados de faturação

+
+ setEnableBillingInfo(checked as boolean)} + /> + +
+ + {/* Billing information form */} +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ) +} diff --git a/website/inertia/components/payments/order_confirmation_modal.tsx b/website/inertia/components/payments/order_confirmation_modal.tsx new file mode 100644 index 0000000..eab5d62 --- /dev/null +++ b/website/inertia/components/payments/order_confirmation_modal.tsx @@ -0,0 +1,36 @@ +import { + Dialog, + DialogContent, + DialogTitle, + DialogHeader, + DialogFooter, + DialogDescription, +} from '~/components/ui/dialog' +import { Button } from '~/components/ui/button' + +interface OrderConfirmationModalProps { + isOpen: boolean + onClose: () => void +} + +function OrderConfirmationModal({ isOpen, onClose }: OrderConfirmationModalProps) { + return ( + + + + Confirmação de Pedido + + + Após realizares o pagamento, irás receber um email de confirmação. + + + + + + + ) +} + +export default OrderConfirmationModal diff --git a/website/inertia/components/payments/payment_method_selector.tsx b/website/inertia/components/payments/payment_method_selector.tsx new file mode 100644 index 0000000..848025b --- /dev/null +++ b/website/inertia/components/payments/payment_method_selector.tsx @@ -0,0 +1,38 @@ +import { RadioGroup, RadioGroupItem } from '~/components/ui/radio-group' +import { Label } from '~/components/ui/label' + +interface PaymentMethodSelectorProps { + paymentMethod: string + setPaymentMethod: (method: string) => void +} + +export default function PaymentMethodSelector({ + paymentMethod, + setPaymentMethod, +}: PaymentMethodSelectorProps) { + return ( +
+

3. Método de pagamento

+ +
+ + +
+ {/* +
+ + +
+ */} +
+
+ ) +} diff --git a/website/inertia/components/payments/phone_modal.tsx b/website/inertia/components/payments/phone_modal.tsx new file mode 100644 index 0000000..ff3a599 --- /dev/null +++ b/website/inertia/components/payments/phone_modal.tsx @@ -0,0 +1,71 @@ +import { + Dialog, + DialogContent, + DialogTitle, + DialogHeader, + DialogFooter, + DialogDescription, +} from '~/components/ui/dialog' +import { Button } from '~/components/ui/button' +import { useState } from 'react' +import { PhoneInput } from '~/components/ui/phone-input' +import { Loader2 } from 'lucide-react' + +interface PhoneNumberModalProps { + isOpen: boolean + isLoading: boolean + onClose: () => void + onSubmit: (phoneNumber: string) => void +} + +function PhoneNumberModal({ isOpen, isLoading, onClose, onSubmit }: PhoneNumberModalProps) { + const [phoneNumber, setPhoneNumber] = useState('') + const [error, setError] = useState('') + + function handleSubmit() { + // TODO improve this validation + if (!phoneNumber || phoneNumber.length < 9 || phoneNumber.length > 16) { + setError('Por favor, insere um número de telemóvel válido') + return + } + setError('') + onSubmit(phoneNumber) + } + + if (!isOpen) { + return null + } + + return ( + + + + Confirmação + + Por favor, insire o teu número de telemóvel: + { + setPhoneNumber(value || '') + setError('') + }} + value={phoneNumber} + placeholder="Número de telemóvel" + /> + {error &&

{error}

} + + + + +
+
+ ) +} + +export default PhoneNumberModal diff --git a/website/inertia/components/payments/purchase_summary.tsx b/website/inertia/components/payments/purchase_summary.tsx new file mode 100644 index 0000000..71a136c --- /dev/null +++ b/website/inertia/components/payments/purchase_summary.tsx @@ -0,0 +1,31 @@ +interface PurchaseSummaryProps { + item: { + name: string + description: string + price: number + image: string + } +} + +export default function PurchaseSummary({ item }: PurchaseSummaryProps) { + return ( +
+

1. Revê a tua compra

+
+ {item.name} +
+

{item.name}

+
+

{item.price.toFixed(2)}€

+
+
+
+ ) +} diff --git a/website/inertia/components/payments/ticket_cart_card.tsx b/website/inertia/components/payments/ticket_cart_card.tsx new file mode 100644 index 0000000..a93a4af --- /dev/null +++ b/website/inertia/components/payments/ticket_cart_card.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { Card, CardTitle, CardDescription, CardContent } from '~/components/ui/card' + +interface TicketCartCardProps { + title: string + description: string + price: number +} + +export const TicketCartCard: React.FC = ({ title, description, price }) => { + return ( + // for small screens, limit the max width, otherwise, use the full width + + +
+ Item +
+ {title} + {description} +
+ {price}€ +
+
+
+ ) +} diff --git a/website/inertia/components/signup/1_personal_info.tsx b/website/inertia/components/signup/1_personal_info.tsx new file mode 100644 index 0000000..dd7fe8c --- /dev/null +++ b/website/inertia/components/signup/1_personal_info.tsx @@ -0,0 +1,177 @@ +import districts from '#data/enei/districts.json' with { type: 'json' } +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form' +import { Input } from '../ui/input' +import { PhoneInput } from '../ui/phone-input/phone-input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { Button } from '~/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover' +import { cn } from '~/lib/utils' +import { Calendar } from '~/components/ui/calendar' +import { CalendarIcon } from 'lucide-react' +import { format } from 'date-fns' +import { pt } from 'date-fns/locale' +import { useForm } from 'react-hook-form' +import { PersonalInfo, personalInfoSchema } from '~/pages/signup/schema' +import { zodResolver } from '@hookform/resolvers/zod' +import { useAtom } from 'jotai/react' +import { personalInfoAtom } from '~/pages/signup/atoms' +import { useStepper } from '../ui/stepper' +import StepperFormActions from './actions' + +const INITIAL_MONTH = new Date(2004, 0, 1) + +const PersonalInfoForm = () => { + const { nextStep } = useStepper() + + const [personalInfo, setPersonalInfo] = useAtom(personalInfoAtom) + + const form = useForm({ + resolver: zodResolver(personalInfoSchema), + defaultValues: personalInfo || { + firstName: '', + lastName: '', + phone: '', + }, + }) + + const onSubmit = (data: PersonalInfo) => { + setPersonalInfo(data) + nextStep() + } + + return ( +
+ +
+
+ ( + + Primeiro Nome* + + + + + + )} + /> + ( + + Último Nome* + + + + + + )} + /> +
+ ( + + Data de Nascimento* + + + + + + + + + + + + + )} + /> + ( + + Número de telemóvel* + + + + Não incluas o código do país. + + + )} + /> + ( + + Natural de* + + + + Indica o distrito onde nasceste. + + + )} + /> + + +
+
+ + ) +} + +export default PersonalInfoForm diff --git a/website/inertia/components/signup/2_student_info.tsx b/website/inertia/components/signup/2_student_info.tsx new file mode 100644 index 0000000..8cf5020 --- /dev/null +++ b/website/inertia/components/signup/2_student_info.tsx @@ -0,0 +1,155 @@ +import { useForm } from 'react-hook-form' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form' +import { Button } from '~/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '~/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover' +import CurricularYearSelector, { CurricularYearSelectorType } from './input/curricular_year_input' + +import { Check, ChevronsUpDown } from 'lucide-react' +import { cn } from '~/lib/utils' +import { Input } from '../ui/input' +import { zodResolver } from '@hookform/resolvers/zod' +import { EducationInfo, educationInfoSchema } from '~/pages/signup/schema' +import { getUniversityById, universities } from '~/lib/enei/signup/universities' +import { useMemo } from 'react' +import { useStepper } from '../ui/stepper' +import { useAtom, useSetAtom } from 'jotai/react' +import { educationInfoAtom } from '~/pages/signup/atoms' +import StepperFormActions from './actions' + +function UniversitySelection({ value }: { value?: string }) { + const name = useMemo(() => value && getUniversityById(value)?.name, [value]) + + return name ? ( + {name} + ) : ( + Selecionar Universidade... + ) +} + +function EducationInfoForm() { + const { nextStep } = useStepper() + + const setEducationInfo = useSetAtom(educationInfoAtom) + const [educationInfo] = useAtom(educationInfoAtom) + + const form = useForm({ + resolver: zodResolver(educationInfoSchema), + defaultValues: educationInfo || { + university: '', + course: '', + curricularYear: ['1', null] as CurricularYearSelectorType, + }, + }) + + const onSubmit = (data: EducationInfo) => { + setEducationInfo(data) + nextStep() + } + + return ( +
+ +
+ ( + + Universidade/Faculdade* + + + + + + + + + Nenhuma universidade encontrada + + {universities.map(({ id, name }) => ( + form.setValue(field.name, id)} + className="flex cursor-pointer items-center justify-between text-sm" + > + {name} + + + ))} + + + + + + + + )} + /> + ( + + Curso* + + + + + + )} + /> + ( + + Ano Curricular* + + { + form.setValue(field.name, [ + curricularYear, + lastYear || null, + ] as CurricularYearSelectorType) + }} + /> + + + + )} + /> + + +
+
+ + ) +} + +export default EducationInfoForm diff --git a/website/inertia/components/signup/3_logistics_info.tsx b/website/inertia/components/signup/3_logistics_info.tsx new file mode 100644 index 0000000..f7df840 --- /dev/null +++ b/website/inertia/components/signup/3_logistics_info.tsx @@ -0,0 +1,154 @@ +import { useForm } from 'react-hook-form' +import { Checkbox } from '../ui/checkbox' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form' +import { Input } from '../ui/input' +import MultipleSelector, { Option } from '../ui/multiple-selector' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { LogisticsInfo, logisticsInfoSchema } from '~/pages/signup/schema' +import transports from '#data/enei/signup/transports.json' with { type: 'json' } +import sizes from '#data/enei/signup/shirts.json' with { type: 'json' } +import { zodResolver } from '@hookform/resolvers/zod' +import { useStepper } from '../ui/stepper' +import { useAtom, useSetAtom } from 'jotai/react' +import { logisticsInfoAtom } from '~/pages/signup/atoms' +import StepperFormActions from './actions' + +const TRANSPORTS: Option[] = transports.map(({ id, description }) => { + return { + label: description, + value: id, + } +}) + +const SIZES = sizes + +const LogisticsInfoForm = () => { + const { nextStep } = useStepper() + + const setLogisticsInfo = useSetAtom(logisticsInfoAtom) + const [logisticsInfo] = useAtom(logisticsInfoAtom) + + const form = useForm({ + resolver: zodResolver(logisticsInfoSchema), + defaultValues: logisticsInfo || { + shirtSize: '', + dietaryRestrictions: '', + isVegetarian: false, + isVegan: false, + transports: [], + }, + }) + + const onSubmit = (data: LogisticsInfo) => { + setLogisticsInfo(data) + nextStep() + } + + return ( +
+ +
+ ( + + Tamanho da T-shirt* + + + + + + )} + /> + + {/* Restrições Alimentares */} + ( + + Restrições alimentares + + + + + + )} + /> + + ( + + + + + +

Vegetariano?

+
+ +
+ )} + /> + + {/* Vegan? */} + ( + + + + + +

Vegan?

+
+ +
+ )} + /> + + {/* Transporte até ao evento */} + ( + + Como estou a pensar deslocar-me para o evento + + + Sem resultados +

+ } + /> +
+ +
+ )} + /> + + +
+
+ + ) +} + +export default LogisticsInfoForm diff --git a/website/inertia/components/signup/4_communication_info.tsx b/website/inertia/components/signup/4_communication_info.tsx new file mode 100644 index 0000000..7787e37 --- /dev/null +++ b/website/inertia/components/signup/4_communication_info.tsx @@ -0,0 +1,191 @@ +import { useForm } from 'react-hook-form' +import { useTuyau } from '~/hooks/use_tuyau' +import { Checkbox } from '../ui/checkbox' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form' +import MultipleSelector, { Option } from '../ui/multiple-selector' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { Textarea } from '../ui/textarea' + +import editions from '#data/enei/editions.json' with { type: 'json' } +import heardaboutfrom from '#data/enei/signup/heard-about.json' with { type: 'json' } +import { zodResolver } from '@hookform/resolvers/zod' +import { + CommunicationsInfo, + communicationsInfoSchema, +} from '~/pages/signup/schema' +import { useAtom, useSetAtom } from 'jotai/react' +import { + personalInfoAtom, + educationInfoAtom, + logisticsInfoAtom, + communicationsInfoAtom, +} from '~/pages/signup/atoms' +import StepperFormActions from './actions' +import { PageProps } from '@adonisjs/inertia/types' +import { router, usePage } from '@inertiajs/react' + +const ENEI_EDITIONS: Option[] = editions + .sort((a, b) => b.year - a.year) + .map(({ year, location }) => { + return { + label: location + ', ' + year.toString(), + value: year.toString(), + } + }) + +const HEARD_ABOUT_FROM: Option[] = heardaboutfrom + +const CommunicationInfoForm = () => { + const tuyau = useTuyau() + + const { csrfToken } = usePage().props; + + const setCommunicationsInfo = useSetAtom(communicationsInfoAtom) + const [communicationsInfo] = useAtom(communicationsInfoAtom) + const [personalInfo] = useAtom(personalInfoAtom) + const [educationInfo] = useAtom(educationInfoAtom) + const [logisticsInfo] = useAtom(logisticsInfoAtom) + + const form = useForm({ + resolver: zodResolver(communicationsInfoSchema), + defaultValues: communicationsInfo || { + heardAboutENEI: '', + reasonForSignup: '', + attendedBefore: false, + attendedBeforeEditions: [], + termsAndConditions: false, + }, + }) + + const onSubmit = async (data: CommunicationsInfo) => { + setCommunicationsInfo(data) + + // data is added to the payload with instead of communicationInfo + // because it does not get updated right away + const payload = { + ...personalInfo, + ...educationInfo, + ...logisticsInfo, + ...data, + _csrf: csrfToken + } + + router.post(tuyau.$url('actions:signup'), payload) + } + + return ( +
+ +
+ ( + + Como ouviste falar do ENEI? + + + + + + )} + /> + + ( + + Qual a principal razão para te inscreveres no ENEI? + +