├── backend ├── config │ ├── __init__.py │ ├── env.py │ └── types.py ├── .env.example ├── enums.py ├── logger.py ├── main_scraper.py ├── main_ad_manager.py ├── scheduler.py ├── utils.py ├── README.md ├── requirements.txt ├── main_sms_scheduler.py ├── main_sms_sender.py ├── sms.py ├── api.py ├── online.py └── db.py ├── frontend ├── .eslintrc.json ├── utils │ ├── crypto │ │ ├── nextEdgeRuntimCryptoModule.ts │ │ ├── nodeCryptoModule.ts │ │ ├── types.ts │ │ └── index.ts │ ├── events │ │ ├── types.ts │ │ ├── parseEventType.ts │ │ └── eventTypeToIcon.tsx │ ├── capitalize.ts │ ├── otp │ │ ├── types.ts │ │ └── index.ts │ ├── shuffleArray.ts │ └── date │ │ └── formatDate.ts ├── public │ ├── favicon.ico │ ├── ow-icon.png │ ├── fallback-img-kurs.png │ ├── fallback-img-bedpres.png │ ├── fallback-img-sosialt.png │ ├── kurs-icon.svg │ ├── bedpres-icon.svg │ ├── sosialt-icon.svg │ ├── checkmark.svg │ ├── location2.svg │ ├── location.svg │ └── loading-spinner.svg ├── postcss.config.js ├── app │ ├── loading.tsx │ ├── dashboard │ │ ├── allEvents.tsx │ │ ├── upcomingRegistrations.tsx │ │ ├── eventFilterHeader.tsx │ │ ├── myEvents.tsx │ │ ├── myEventsList.tsx │ │ ├── upcomingRegistrationsEventsList.tsx │ │ ├── page.tsx │ │ └── allEventsList.tsx │ ├── bingo │ │ ├── page.tsx │ │ ├── controls.tsx │ │ ├── board.tsx │ │ ├── utils.ts │ │ └── game.tsx │ ├── layout.tsx │ ├── head.tsx │ ├── register │ │ ├── registerSteps.tsx │ │ ├── page.tsx │ │ ├── eventPreferenceSelection.tsx │ │ └── phoneVerification.tsx │ ├── globals.css │ ├── page.tsx │ └── auth │ │ └── redirect │ │ └── page.tsx ├── providers │ ├── ReactQueryProvider.tsx │ ├── index.tsx │ ├── EventFilterContext.tsx │ └── EnvironmentContext.tsx ├── components │ ├── LoadingSpinner.tsx │ ├── icons │ │ ├── KursIcon.tsx │ │ ├── BedpresIcon.tsx │ │ └── SosialtIcon.tsx │ ├── PrimaryToggle.tsx │ ├── LoginButton.tsx │ ├── EventTypeToggle.tsx │ └── VerificationCodeInput.tsx ├── next.config.js ├── database │ ├── models │ │ ├── Exclusion.ts │ │ ├── Ad.ts │ │ ├── Subscriber.ts │ │ ├── Image.ts │ │ ├── OWData.ts │ │ └── Event.ts │ └── index.ts ├── pages │ └── api │ │ ├── auth │ │ ├── user.ts │ │ └── accessToken.ts │ │ ├── register │ │ └── verifyPhoneNumber.ts │ │ ├── sms │ │ └── otp │ │ │ ├── verify.ts │ │ │ └── generate.ts │ │ └── notification │ │ └── [eventId].ts ├── .gitignore ├── tsconfig.json ├── .env.local.example ├── README.md ├── tailwind.config.js ├── package.json ├── middleware.ts └── auth │ └── index.ts ├── .gitignore ├── .github └── workflows │ └── gc-storage-sync.yml └── README.md /backend/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env_backup 3 | env/ 4 | __pycache__/ 5 | .DS_STORE 6 | .vscode 7 | 8 | -------------------------------------------------------------------------------- /frontend/utils/crypto/nextEdgeRuntimCryptoModule.ts: -------------------------------------------------------------------------------- 1 | export default globalThis.crypto; 2 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndreasWintherMoen/jobb/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/ow-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndreasWintherMoen/jobb/HEAD/frontend/public/ow-icon.png -------------------------------------------------------------------------------- /frontend/utils/crypto/nodeCryptoModule.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto'; 2 | 3 | export default crypto.webcrypto; 4 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | MONGO_URI= 2 | TWILIO_ACCOUNT_SID= 3 | TWILIO_AUTH_TOKEN= 4 | TWILIO_MESSAGING_SERVICE_SID= 5 | OW_COOKIE= 6 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/public/fallback-img-kurs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndreasWintherMoen/jobb/HEAD/frontend/public/fallback-img-kurs.png -------------------------------------------------------------------------------- /frontend/public/fallback-img-bedpres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndreasWintherMoen/jobb/HEAD/frontend/public/fallback-img-bedpres.png -------------------------------------------------------------------------------- /frontend/public/fallback-img-sosialt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndreasWintherMoen/jobb/HEAD/frontend/public/fallback-img-sosialt.png -------------------------------------------------------------------------------- /frontend/utils/events/types.ts: -------------------------------------------------------------------------------- 1 | export type EventIndex = 1 | 2 | 3 | 4; 2 | 3 | export type EventType = 'bedpres' | 'kurs' | 'sosialt'; 4 | -------------------------------------------------------------------------------- /frontend/utils/capitalize.ts: -------------------------------------------------------------------------------- 1 | export default function capitalize(str: string) { 2 | return str.charAt(0).toUpperCase() + str.slice(1); 3 | } 4 | -------------------------------------------------------------------------------- /backend/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class MessageType(Enum): 4 | CUSTOM = 0 5 | REGISTRATION_START = 1 6 | UNATTEND = 2 7 | EVENT_START = 3 8 | -------------------------------------------------------------------------------- /frontend/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import LoadingSpinner from '../components/LoadingSpinner'; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/utils/otp/types.ts: -------------------------------------------------------------------------------- 1 | export type OTP = { 2 | code: string; 3 | cipher: string; 4 | }; 5 | 6 | export type OTPResponse = { 7 | isValid: boolean; 8 | httpCode: number; 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/public/kurs-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/utils/shuffleArray.ts: -------------------------------------------------------------------------------- 1 | export default function shuffleArray(array: unknown[]) { 2 | for (let i = array.length - 1; i > 0; i--) { 3 | const j = Math.floor(Math.random() * (i + 1)); 4 | [array[i], array[j]] = [array[j], array[i]]; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/app/dashboard/allEvents.tsx: -------------------------------------------------------------------------------- 1 | import database from '../../database'; 2 | import AllEventsList from './allEventsList'; 3 | 4 | export default async function AllEvents() { 5 | const events = await database.fetchEvents(); 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/app/bingo/page.tsx: -------------------------------------------------------------------------------- 1 | import BingoGame from './game'; 2 | 3 | export default function Bingo() { 4 | return ( 5 |
6 |

7 | Bedpres Bingo 8 |

9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/public/bedpres-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css'; 2 | import Providers from '../providers'; 3 | 4 | export default function RootLayout({ 5 | children, 6 | }: { 7 | children: React.ReactNode; 8 | }) { 9 | return ( 10 | 11 | 12 | 13 | {children} 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/providers/ReactQueryProvider.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from 'react-query'; 2 | 3 | const queryClient = new QueryClient(); 4 | 5 | export function ReactQueryProvider({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | return ( 11 | {children} 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | export default function LoadingSpinner() { 4 | return ( 5 |
6 | Loading... 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | images: { 7 | remotePatterns: [ 8 | { 9 | protocol: 'https', 10 | hostname: 'onlineweb4-prod.s3.eu-north-1.amazonaws.com', 11 | pathname: '/media/images/**', 12 | }, 13 | ], 14 | }, 15 | }; 16 | 17 | module.exports = nextConfig; 18 | -------------------------------------------------------------------------------- /frontend/utils/events/parseEventType.ts: -------------------------------------------------------------------------------- 1 | import { EventIndex, EventType } from './types'; 2 | 3 | export const parseEventType = (index: EventIndex): EventType => { 4 | if (index === 1 || index === 4) return 'sosialt'; 5 | if (index === 2) return 'bedpres'; 6 | if (index === 3) return 'kurs'; 7 | console.warn( 8 | `Unknown event index: ${index} in parseEventType. Defaulting to 'sosialt'` 9 | ); 10 | return 'sosialt'; 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/public/sosialt-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/utils/crypto/types.ts: -------------------------------------------------------------------------------- 1 | export interface IEncrypted { 2 | cipher: ArrayBuffer; 3 | iv: Uint8Array; 4 | } 5 | 6 | export interface IJWTPayload { 7 | iss: string; 8 | sub: string; 9 | aud: string; 10 | exp: number; 11 | iat: number; 12 | auth_time: number; 13 | at_hash: string; 14 | } 15 | 16 | export interface IJwk { 17 | alg: string; 18 | e: string; 19 | kid: string; 20 | kty: string; 21 | n: string; 22 | use: string; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/database/models/Exclusion.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const ExclusionSchema = new mongoose.Schema({ 4 | subscriber_id: { type: mongoose.Schema.Types.Number, required: true }, 5 | event_id: { type: mongoose.Schema.Types.Number, required: true }, 6 | }); 7 | 8 | export interface IExclusion { 9 | _id?: mongoose.Types.ObjectId; 10 | subscriber_id: number; 11 | event_id: number; 12 | } 13 | 14 | export default mongoose.model('Exclusion', ExclusionSchema); 15 | -------------------------------------------------------------------------------- /frontend/app/bingo/controls.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useBingoContext } from './game'; 4 | 5 | export default function BingoControls() { 6 | const { restart } = useBingoContext(); 7 | 8 | return ( 9 |
10 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /frontend/app/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | Bedpres Bot 5 | 6 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/pages/api/auth/user.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import auth from '../../../auth'; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== 'GET') { 9 | res.status(405).json({ error: 'Method not allowed' }); 10 | } 11 | 12 | try { 13 | const data = await auth.fetchUser(); 14 | res.status(200).json(data); 15 | } catch (error) { 16 | res.status(500).json({ error }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /frontend/components/icons/KursIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function Icon({ 2 | color, 3 | width, 4 | height, 5 | }: { 6 | color?: string; 7 | width?: number; 8 | height?: number; 9 | }) { 10 | return ( 11 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/public/checkmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/providers/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | // import AuthProvider from './AuthContext'; 4 | import EnvironmentProvider from './EnvironmentContext'; 5 | import { ReactQueryProvider } from './ReactQueryProvider'; 6 | import EventFilterProvider from './EventFilterContext'; 7 | 8 | export default function Providers({ children }: { children: React.ReactNode }) { 9 | return ( 10 | 11 | 12 | {children} 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /frontend/app/register/registerSteps.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useState } from 'react'; 4 | import EventPreferenceSelection from './eventPreferenceSelection'; 5 | import PhoneVerification from './phoneVerification'; 6 | 7 | export default function RegisterSteps({ owUser }: any) { 8 | const [step, setStep] = useState(1); 9 | 10 | if (step === 1) { 11 | return ( 12 | setStep(2)} 15 | /> 16 | ); 17 | } 18 | if (step === 2) { 19 | return ; 20 | } 21 | return
; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/pages/api/register/verifyPhoneNumber.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | export default async function handler( 4 | req: NextApiRequest, 5 | res: NextApiResponse 6 | ) { 7 | if (req.method !== 'POST') { 8 | res.status(405).json({ error: 'Method not allowed' }); 9 | } 10 | 11 | const { phoneNumber } = req.body; 12 | if (!phoneNumber) { 13 | res.status(400).json({ error: 'Missing parameters' }); 14 | } 15 | 16 | // const data = await auth.verifyPhoneNumber(phoneNumber); 17 | 18 | // res.status(200).json(data); 19 | res.status(200).json({ phoneNumber }); // remove this line 20 | } 21 | -------------------------------------------------------------------------------- /frontend/public/location2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/icons/BedpresIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function Icon({ 2 | color, 3 | width, 4 | height, 5 | }: { 6 | color?: string; 7 | width?: number; 8 | height?: number; 9 | }) { 10 | return ( 11 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ] 22 | }, 23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/location.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/database/models/Ad.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const AdSchema = new mongoose.Schema({ 4 | _id: { 5 | type: mongoose.Schema.Types.String, 6 | unique: true, 7 | required: true, 8 | index: true, 9 | }, 10 | key: { type: mongoose.Schema.Types.String, unique: true, required: true }, 11 | text: { type: mongoose.Schema.Types.String, required: true }, 12 | is_active: { type: mongoose.Schema.Types.Boolean, required: true }, 13 | priority_order: { type: mongoose.Schema.Types.Number, required: true }, 14 | }); 15 | 16 | export interface IAd { 17 | _id?: string; 18 | key: string; 19 | text: string; 20 | is_active: boolean; 21 | priority_order: number; 22 | } 23 | 24 | export default mongoose.model('Ad', AdSchema); 25 | -------------------------------------------------------------------------------- /frontend/pages/api/sms/otp/verify.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { verifyOtp } from '../../../../utils/otp'; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== 'POST') { 9 | return res.status(405).json({ error: 'Method not allowed' }); 10 | } 11 | 12 | const { cipher, code, phoneNumber } = req.body; 13 | if (!phoneNumber || !code || !cipher || typeof code !== 'string') { 14 | return res.status(400).json({ error: 'Missing or invalid parameters' }); 15 | } 16 | 17 | const otp = { cipher, code }; 18 | const { isValid, httpCode } = await verifyOtp(otp, phoneNumber); 19 | 20 | return res.status(httpCode).json({ isValid }); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/.env.local.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_OW_AUTHORIZE_URL=https://old.online.ntnu.no/openid/authorize 2 | NEXT_PUBLIC_OW_TOKEN_URL=https://old.online.ntnu.no/openid/token 3 | NEXT_PUBLIC_OW_USERINFO_URL=https://old.online.ntnu.no/openid/userinfo 4 | NEXT_PUBLIC_REDIRECT_URI=https://bedpresbot.online/auth/redirect 5 | OW_AUTHORIZE_URL=https://old.online.ntnu.no/openid/authorize 6 | OW_TOKEN_URL=https://old.online.ntnu.no/openid/token 7 | OW_USERINFO_URL=https://old.online.ntnu.no/openid/userinfo 8 | OW_PROFILE_URL=https://old.online.ntnu.no/api/v1/profile 9 | OW_JWK_URL=https://old.online.ntnu.no/openid/jwks 10 | REDIRECT_URI=https://bedpresbot.online/auth/redirect 11 | CLIENT_ID= 12 | MONGODB_URI= 13 | ENCRYPTION_KEY= 14 | DEV_PHONE_NUMBER= 15 | TWILIO_ACCOUNT_SID= 16 | TWILIO_AUTH_TOKEN= -------------------------------------------------------------------------------- /frontend/components/icons/SosialtIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function Icon({ 2 | color, 3 | width, 4 | height, 5 | }: { 6 | color?: string; 7 | width?: number; 8 | height?: number; 9 | }) { 10 | return ( 11 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/app/register/page.tsx: -------------------------------------------------------------------------------- 1 | export const dynamic = 'force-dynamic'; 2 | 3 | import auth from '../../auth'; 4 | 5 | import { headers } from 'next/headers'; 6 | import RegisterSteps from './registerSteps'; 7 | 8 | export default async function RegisterPage() { 9 | let owUser: any = {}; 10 | let token: string | null = ''; 11 | try { 12 | const nextHeaders = headers(); 13 | token = nextHeaders.get('x-ow-token'); 14 | owUser = await auth.fetchFullProfile(token || undefined); 15 | } catch (e: any) { 16 | return ( 17 |
18 |

No user

19 |

Caught error: {e.message}

20 |

Token: {token}

21 |
22 | ); 23 | } 24 | if (!owUser || !owUser.phone_number) { 25 | return
no user
; 26 | } 27 | 28 | return ; 29 | } 30 | -------------------------------------------------------------------------------- /frontend/database/models/Subscriber.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { IOWData, OWDataSchema } from './OWData'; 3 | 4 | const SubscriberSchema = new mongoose.Schema({ 5 | _id: { type: mongoose.Schema.Types.ObjectId }, 6 | phone_number: { 7 | type: mongoose.Schema.Types.String, 8 | unique: true, 9 | required: true, 10 | }, 11 | ow: { type: OWDataSchema, ref: 'OWData', required: true }, 12 | should_receive_ads: { type: mongoose.Schema.Types.Boolean, required: true }, 13 | ads_received: { type: [mongoose.Schema.Types.String], required: true }, 14 | }); 15 | 16 | export interface ISubscriber { 17 | _id?: mongoose.Types.ObjectId; 18 | phone_number: string; 19 | ow: IOWData; 20 | should_receive_ads: boolean; 21 | ads_received: string[]; 22 | } 23 | 24 | export default mongoose.model('Subscriber', SubscriberSchema); 25 | -------------------------------------------------------------------------------- /frontend/app/dashboard/upcomingRegistrations.tsx: -------------------------------------------------------------------------------- 1 | import auth from '../../auth'; 2 | import database from '../../database'; 3 | import EventList from './upcomingRegistrationsEventsList'; 4 | 5 | export default async function UpcomingRegistrations() { 6 | const events = await database.fetchUpcomingRegistrations(); 7 | 8 | // TODO: Implement this with dynamic id from OAuth 9 | // const user = await auth.fetchUser(); 10 | // const exclusions = await database.fetchExclusions(user.sub); 11 | const exclusions = await database.fetchExclusions(1421); 12 | 13 | const eventsWithNotificationStatus = events.map((event) => { 14 | const sendNotification = exclusions.every( 15 | (exclusion) => exclusion.event_id !== event.id 16 | ); 17 | return { 18 | ...event, 19 | sendNotification, 20 | }; 21 | }); 22 | 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/app/bingo/board.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useBingoContext } from './game'; 4 | 5 | export default function BingoBoard() { 6 | const { cells, selected, toggleCell } = useBingoContext(); 7 | 8 | const completedStyling = 'bg-owSecondary text-background'; 9 | 10 | return ( 11 |
12 | {cells.map((row, i) => ( 13 |
14 | {row.map((term, j) => ( 15 |
toggleCell(i, j)} 22 | > 23 | {term} 24 |
25 | ))} 26 |
27 | ))} 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Bedpres Bot 2 | 3 | This is the web application for Bedpres Bot. It's made in NextJS and is therefore both front and back-end in the same application. I'm using NextJS 13 with the app directory which is currently in beta. Thus, the pages and routing are located in the _app_ directory as opposed to _pages_ directory which was the standard for NextJS <= 12. This README won't provide more information about NextJS or SSG vs SSR vs CSR, so find a tutorial if necessary. 4 | 5 | ## How to run 6 | 7 | 1. Create a file \_.env.local*. See *.env.local.example\*. Ask me if you need database access. If you're going to send an SMS (used to send OTP when registering new users) you have to set up Twilio and add API keys to the application. It costs about $0.05 per SMS and you have to pay that yourself :) You get $15 or so for free, and you don't really need SMS support in most of the application, as most of it is handled in the serverless functions in the backend folder. 8 | 9 | 2. 10 | 11 | ```bash 12 | npm i 13 | ``` 14 | 15 | 3. 16 | 17 | ```bash 18 | npm run dev 19 | ``` 20 | -------------------------------------------------------------------------------- /backend/config/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Literal, cast 3 | 4 | if ('ENVIRONMENT' not in os.environ): 5 | from dotenv import load_dotenv 6 | load_dotenv() 7 | 8 | environment_type = Literal['dev', 'prod_gc', 'prod_api'] 9 | ENVIRONMENT = os.environ.get('ENVIRONMENT', 'dev') 10 | if (ENVIRONMENT not in ['dev', 'prod_gc', 'prod_api']): 11 | raise Exception(f'Invalid environment type: {ENVIRONMENT}. Must be one of: dev, prod_gc, prod_api') 12 | ENVIRONMENT = cast(environment_type, ENVIRONMENT) 13 | 14 | MONGO_URI = os.environ.get('MONGO_URI') 15 | OW_COOKIE = os.environ.get('OW_COOKIE') 16 | TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID') 17 | TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN') 18 | TWILIO_MESSAGING_SERVICE_SID = os.environ.get('TWILIO_MESSAGING_SERVICE_SID') 19 | SCHEDULER_PROJECT_ID = os.environ.get('GC_PROJECT_ID') 20 | SCHEDULER_QUEUE = os.environ.get('GC_QUEUE') 21 | SCHEDULER_LOCATION = os.environ.get('GC_LOCATION') 22 | SERVICE_ACCOUNT_EMAIL = os.environ.get('SERVICE_ACCOUNT_EMAIL') 23 | SMS_SEND_URL = os.environ.get('SMS_SEND_URL') -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | const colors = require('tailwindcss/colors'); 4 | 5 | module.exports = { 6 | content: [ 7 | './pages/**/*.{js,ts,jsx,tsx}', 8 | './components/**/*.{js,ts,jsx,tsx}', 9 | './app/**/*.{js,ts,jsx,tsx}', 10 | ], 11 | theme: { 12 | colors: { 13 | owPrimary: '#0E5474', 14 | owSecondary: { 15 | DEFAULT: '#F9B759', 16 | accent: '#EEAC4E', 17 | }, 18 | background: { 19 | DEFAULT: '#252525', 20 | accent: '#2a2a2a', 21 | light: '#2F2F2F', 22 | dark: '#191919', 23 | }, 24 | textPrimary: '#FFFFFF', 25 | textAccent: '#BDBDBD', 26 | event: { 27 | bedpres: '#EB536E', 28 | kurs: '#127DBD', 29 | sosialt: '#43B171', 30 | }, 31 | transparent: 'transparent', 32 | error: '#CC0000', 33 | ...colors, 34 | }, 35 | extend: { 36 | borderRadius: { 37 | xl: '16px', 38 | }, 39 | }, 40 | }, 41 | plugins: [require('@tailwindcss/line-clamp')], 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BedpresBot", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@next/font": "13.1.1", 13 | "@types/node": "18.11.18", 14 | "@types/react": "18.0.26", 15 | "@types/react-dom": "18.0.10", 16 | "eslint": "8.31.0", 17 | "eslint-config-next": "13.1.1", 18 | "framer-motion": "^8.5.0", 19 | "mongoose": "^6.8.3", 20 | "next": "13.1.1", 21 | "next-auth": "^4.18.7", 22 | "oidc-client-ts": "^2.2.0", 23 | "pkce-challenge": "^3.0.0", 24 | "react": "18.2.0", 25 | "react-confetti": "^6.1.0", 26 | "react-dom": "18.2.0", 27 | "react-oidc-context": "^2.2.0", 28 | "react-query": "^3.39.2", 29 | "server-only": "^0.0.1", 30 | "twilio": "^3.84.1", 31 | "typescript": "4.9.4" 32 | }, 33 | "devDependencies": { 34 | "@tailwindcss/line-clamp": "^0.4.2", 35 | "autoprefixer": "^10.4.13", 36 | "postcss": "^8.4.20", 37 | "tailwindcss": "^3.2.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import type { NextRequest } from 'next/server'; 3 | import { decrypt } from './utils/crypto'; 4 | import cryptoModule from './utils/crypto/nextEdgeRuntimCryptoModule'; 5 | 6 | export async function middleware(request: NextRequest) { 7 | const token = request.cookies.get('token'); 8 | 9 | if (!token?.value) { 10 | const requestHeaders = new Headers(request.headers); 11 | requestHeaders.set('x-ow-token', 'No token found'); 12 | return NextResponse.next({ 13 | request: { 14 | headers: requestHeaders, 15 | }, 16 | }); 17 | } 18 | 19 | try { 20 | const decryptedToken = await decrypt(token.value, cryptoModule, true); 21 | const requestHeaders = new Headers(request.headers); 22 | requestHeaders.set('x-ow-token', decryptedToken); 23 | return NextResponse.next({ 24 | request: { 25 | headers: requestHeaders, 26 | }, 27 | }); 28 | } catch (e) { 29 | const requestHeaders = new Headers(request.headers); 30 | requestHeaders.set('x-ow-token', `Error decrypting token.... Error: ${e}`); 31 | return NextResponse.next({ 32 | request: { 33 | headers: requestHeaders, 34 | }, 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/pages/api/auth/accessToken.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import auth from '../../../auth'; 3 | import { encrypt } from '../../../utils/crypto'; 4 | import cryptoModule from '../../../utils/crypto/nodeCryptoModule'; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | if (req.method !== 'POST') { 11 | res.status(405).json({ error: 'Method not allowed' }); 12 | } 13 | const { authCode, codeVerifier } = req.body; 14 | if (!authCode || !codeVerifier) { 15 | res.status(400).json({ 16 | error: `Missing parameters. Provided authCode: ${authCode}. Provided codeVerifier: ${codeVerifier}`, 17 | }); 18 | } 19 | 20 | try { 21 | const data = await auth.fetchAccessToken(authCode, codeVerifier); 22 | const encryptedAccessToken = await encrypt(data.access_token, cryptoModule); 23 | const encryptedRefreshToken = await encrypt( 24 | data.refresh_token, 25 | cryptoModule 26 | ); 27 | 28 | const encryptedData = { 29 | ...data, 30 | access_token: encryptedAccessToken, 31 | refresh_token: encryptedRefreshToken, 32 | }; 33 | res.status(200).json(encryptedData); 34 | } catch (error) { 35 | res.status(500).json({ error }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from config.env import ENVIRONMENT 3 | 4 | class Logger: 5 | def __init__(self): 6 | logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] %(levelname)s: %(message)s') 7 | if (ENVIRONMENT == 'prod_gc'): 8 | import google.cloud.logging 9 | gc_logging_client = google.cloud.logging.Client() 10 | gc_logging_client.setup_logging() 11 | 12 | def debug(self, message: object) -> None: 13 | logging.debug(message) 14 | 15 | def info(self, message: object) -> None: 16 | logging.info(message) 17 | 18 | def warning(self, message: object) -> None: 19 | logging.warning(message) 20 | 21 | def error(self, message: object) -> None: 22 | logging.error(message) 23 | 24 | def critical(self, message: object) -> None: 25 | logging.critical(message) 26 | 27 | 28 | logger = Logger() 29 | 30 | def debug(message: object) -> None: 31 | logger.debug(message) 32 | 33 | def info(message: object) -> None: 34 | logger.info(message) 35 | 36 | def warning(message: object) -> None: 37 | logger.warning(message) 38 | 39 | def error(message: object) -> None: 40 | logger.error(message) 41 | 42 | def critical(message: object) -> None: 43 | logger.critical(message) 44 | -------------------------------------------------------------------------------- /frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --max-width: 1200px; 7 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 8 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 9 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; 10 | } 11 | 12 | * { 13 | box-sizing: border-box; 14 | padding: 0; 15 | margin: 0; 16 | } 17 | 18 | html, 19 | body { 20 | max-width: 100vw; 21 | overflow-x: hidden; 22 | color-scheme: dark; 23 | background-color: #252525; 24 | color: #ffffff; 25 | box-sizing: border-box; 26 | } 27 | 28 | *, 29 | *:before, 30 | *:after { 31 | box-sizing: inherit; 32 | } 33 | 34 | /* Custom Tailwind CSS */ 35 | @layer components { 36 | .checkmark--bedpres { 37 | @apply border-t-event-bedpres border-l-event-bedpres; 38 | } 39 | .checkmark--kurs { 40 | @apply border-t-event-kurs border-l-event-kurs; 41 | } 42 | .checkmark--sosialt { 43 | @apply border-t-event-sosialt border-l-event-sosialt; 44 | } 45 | 46 | .toggleBorder--bedpres { 47 | @apply border-t-event-bedpres; 48 | } 49 | .toggleBorder--kurs { 50 | @apply border-t-event-kurs; 51 | } 52 | .toggleBorder--sosialt { 53 | @apply border-t-event-sosialt; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LoginButton from '../components/LoginButton'; 3 | 4 | export default function Home() { 5 | return ( 6 |
7 |
8 |
9 |

10 | Bedpres Bot 11 |

12 |

Bedpres Bot er under utvikling...

13 |

14 | Få varsling om 15 | bedriftspresentasjoner og andre{' '} 16 | arrangementer. Unngå å få 17 | prikk fordi du glemte å melde deg av et arrangement. Registrer deg{' '} 18 | gratis med din 19 | Online-bruker. 20 |

21 |
22 |
23 | 24 |
25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/app/register/eventPreferenceSelection.tsx: -------------------------------------------------------------------------------- 1 | import EventTypeToggle from '../../components/EventTypeToggle'; 2 | 3 | export default function EventPreferenceSelectionPage() { 4 | return ( 5 |
6 |

7 | Velkommen! 8 |

9 |

10 | Hvilke arrangementer vil du varsles om? 11 |

12 |
13 | 14 | 15 | 16 |
17 |

18 | Dette er bare en preferanse. Når du er logget inn kan du velge hvilke 19 | arrangementer du vil få varsling om. 20 |

21 |
22 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/database/models/Image.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | export const ImageSchema = new mongoose.Schema({ 4 | id: { type: mongoose.Schema.Types.Number, unique: true, required: true }, 5 | description: { type: mongoose.Schema.Types.String }, 6 | lg: { type: mongoose.Schema.Types.String }, 7 | md: { type: mongoose.Schema.Types.String }, 8 | sm: { type: mongoose.Schema.Types.String }, 9 | xs: { type: mongoose.Schema.Types.String }, 10 | original: { type: mongoose.Schema.Types.String }, 11 | name: { type: mongoose.Schema.Types.String }, 12 | photographer: { type: mongoose.Schema.Types.String }, 13 | preset: { type: mongoose.Schema.Types.String }, 14 | preset_display: { type: mongoose.Schema.Types.String }, 15 | tags: { type: [mongoose.Schema.Types.String] }, 16 | thumb: { type: mongoose.Schema.Types.String }, 17 | timestamp: { type: mongoose.Schema.Types.String }, 18 | wide: { type: mongoose.Schema.Types.String }, 19 | }); 20 | 21 | export interface IImage { 22 | id: number; 23 | description?: string; 24 | lg?: string; 25 | md?: string; 26 | sm?: string; 27 | xs?: string; 28 | original?: string; 29 | name?: string; 30 | photographer?: string; 31 | preset?: string; 32 | preset_display?: string; 33 | tags?: string[]; 34 | thumb?: string; 35 | timestamp?: string; 36 | wide?: string; 37 | } 38 | -------------------------------------------------------------------------------- /frontend/app/dashboard/eventFilterHeader.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import PrimaryButton from '../../components/PrimaryToggle'; 4 | import { useEventFilterContext } from '../../providers/EventFilterContext'; 5 | 6 | export default function FilterHeader() { 7 | const { selectedEventTypeFilter, setSelectedEventTypeFilter } = 8 | useEventFilterContext(); 9 | return ( 10 |
11 | setSelectedEventTypeFilter('alle')} 14 | color='owSecondary' 15 | > 16 | Alle 17 | 18 | setSelectedEventTypeFilter('bedpres')} 21 | color='event-bedpres' 22 | > 23 | Bedpres 24 | 25 | setSelectedEventTypeFilter('kurs')} 28 | color='event-kurs' 29 | > 30 | Kurs 31 | 32 | setSelectedEventTypeFilter('sosialt')} 35 | color='event-sosialt' 36 | > 37 | Sosialt 38 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /frontend/utils/otp/index.ts: -------------------------------------------------------------------------------- 1 | import { decrypt, encrypt } from '../crypto'; 2 | import nodeCryptoModule from '../crypto/nodeCryptoModule'; 3 | import { OTP, OTPResponse } from './types'; 4 | 5 | function generateCode() { 6 | let otp = ''; 7 | for (let i = 0; i < 5; i++) { 8 | otp += Math.floor(Math.random() * 10).toString(); 9 | } 10 | return otp; 11 | } 12 | 13 | export async function generateEncryptedOtp(phoneNumber: string): Promise { 14 | const code = generateCode(); 15 | const ttl = 60 * 5; // 5 minutes 16 | const expirationTime = Math.floor(Date.now() / 1000) + ttl; 17 | const data = `${phoneNumber}.${code}.${expirationTime}`; 18 | const cipher = await encrypt(data, nodeCryptoModule); 19 | return { cipher, code }; 20 | } 21 | 22 | export async function verifyOtp( 23 | otp: OTP, 24 | phoneNumber: string 25 | ): Promise { 26 | const { cipher, code } = otp; 27 | const data = await decrypt(cipher, nodeCryptoModule); 28 | const [originalPhoneNumber, originalCode, expirationTime] = data.split('.'); 29 | const currentTimestamp = Math.floor(Date.now() / 1000); 30 | if (currentTimestamp > Number(expirationTime)) { 31 | return { isValid: false, httpCode: 410 }; 32 | } 33 | const isValid = code === originalCode && phoneNumber === originalPhoneNumber; 34 | if (!isValid) return { isValid, httpCode: 401 }; 35 | return { isValid, httpCode: 200 }; 36 | } 37 | -------------------------------------------------------------------------------- /backend/main_scraper.py: -------------------------------------------------------------------------------- 1 | from config.types import Event 2 | from online import get_event_list, event_is_in_the_future, add_attendance_event_data_to_event 3 | from db import Database 4 | import logger 5 | 6 | def is_relevant_event(event: Event) -> bool: 7 | logger.info(f"Analyzing event [{event['id']}] {event['title']}...") 8 | if event['is_attendance_event'] == False: 9 | return False 10 | if not event_is_in_the_future(event): 11 | return False 12 | return True 13 | 14 | def discover_new_bedpres_and_add_to_database(data, context): 15 | logger.info("********* STARTING SCRAPER... *********") 16 | database = Database() 17 | database.connect() 18 | events = get_event_list() 19 | total_count = len(events) 20 | logger.info(f"Fetched {total_count} events") 21 | events = [event for event in events if is_relevant_event(event)] 22 | filtered_count = len(events) 23 | logger.info(f"Filtered {total_count} events to {filtered_count} events") 24 | events = [add_attendance_event_data_to_event(event) for event in events] 25 | already_added_events = [event for event in events if database.event_exists_in_database(event['id'])] 26 | new_events = [event for event in events if not database.event_exists_in_database(event['id'])] 27 | database.update_events_in_database(already_added_events) 28 | database.add_events_to_database(new_events) 29 | database.disconnect() 30 | -------------------------------------------------------------------------------- /frontend/pages/api/notification/[eventId].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import auth from '../../../auth'; 3 | import database from '../../../database'; 4 | 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | console.log('notification/toggle'); 10 | console.log(req.method); 11 | if (req.method !== 'POST' && req.method !== 'DELETE') { 12 | return res.status(405).json({ error: 'Method not allowed' }); 13 | } 14 | 15 | const { eventId } = req.query; 16 | if (!eventId || typeof eventId !== 'string' || isNaN(Number(eventId))) { 17 | return res.status(400).json({ error: 'Bad request' }); 18 | } 19 | 20 | const sendNotification = req.method === 'POST'; 21 | 22 | try { 23 | // TODO: Implement this 24 | // const user = await auth.fetchUser(); 25 | 26 | const user = { 27 | id: 1421, 28 | }; 29 | 30 | const exclusion = { 31 | subscriber_id: user.id, 32 | event_id: parseInt(eventId), 33 | }; 34 | if (sendNotification) { 35 | await database.insertExclusion(exclusion); 36 | } else { 37 | await database.removeExclusion(exclusion); 38 | } 39 | res.status(200).json({ status: 'OK' }); 40 | } catch (err) { 41 | console.log('error '); 42 | console.log(err); 43 | const error = 'Something went wrong'; 44 | res.status(500).json({ error }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/utils/events/eventTypeToIcon.tsx: -------------------------------------------------------------------------------- 1 | import { EventIndex, EventType } from './types'; 2 | import BedpresIcon from '../../components/icons/BedpresIcon'; 3 | import KursIcon from '../../components/icons/KursIcon'; 4 | import SosialtIcon from '../../components/icons/SosialtIcon'; 5 | import { parseEventType } from './parseEventType'; 6 | 7 | const tailwindConfig = require('../../tailwind.config'); 8 | 9 | export default function eventTypeToIcon( 10 | eventType: EventType | EventIndex, 11 | options?: Options 12 | ) { 13 | if (typeof eventType === 'number') eventType = parseEventType(eventType); 14 | if (eventType === 'bedpres') 15 | return ( 16 | 21 | ); 22 | if (eventType === 'kurs') 23 | return ( 24 | 29 | ); 30 | if (eventType === 'sosialt') 31 | return ( 32 | 37 | ); 38 | } 39 | 40 | export type Options = { 41 | color?: string; 42 | size?: number; 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/app/bingo/utils.ts: -------------------------------------------------------------------------------- 1 | import shuffleArray from '../../utils/shuffleArray'; 2 | 3 | // TODO: Load terms from a file 4 | const bingoTerms = [ 5 | 'Reelt prosjekt', 6 | 'Mye sosialt', 7 | 'Buddy / mentor', 8 | 'Faggrupper', 9 | 'React', 10 | 'Jenteandel', 11 | 'Tur til utlandet', 12 | 'Flinke folk', 13 | 'Ungt miljø', 14 | 'Greit å feile', 15 | 'Shuffleboard', 16 | 'Ta ned prod', 17 | 'Kaffe', 18 | 'Kake', 19 | 'Sertifisering', 20 | 'Overgang fra studiet', 21 | 'Java', 22 | 'C# og .NET', 23 | 'Kotlin', 24 | 'TypeScript', 25 | 'Frontend utvikler', 26 | 'Prøve meg på backend', 27 | 'Studerte også informatikk på NTNU', 28 | 'Viser bilde av ansatte med øl i hånden', 29 | 'Konsulent-CV', 30 | 'Graduate program', 31 | 'Fleksitid', 32 | ]; 33 | 34 | export function getNewBingoTerms(): string[][] { 35 | shuffleArray(bingoTerms); 36 | return [ 37 | [bingoTerms[0], bingoTerms[1], bingoTerms[2], bingoTerms[3]], 38 | [bingoTerms[4], bingoTerms[5], bingoTerms[6], bingoTerms[7]], 39 | [bingoTerms[8], bingoTerms[9], bingoTerms[10], bingoTerms[11]], 40 | [bingoTerms[12], bingoTerms[13], bingoTerms[14], bingoTerms[15]], 41 | ]; 42 | } 43 | 44 | export function isBingo(board: boolean[][]): boolean { 45 | const row = board.some((row) => row.every((cell) => cell)); 46 | const col = board[0].some((_, i) => board.every((row) => row[i])); 47 | const diag = board.every((_, i) => board[i][i]); 48 | const antiDiag = board.every((_, i) => board[i][board.length - i - 1]); 49 | return row || col || diag || antiDiag; 50 | } 51 | -------------------------------------------------------------------------------- /frontend/providers/EventFilterContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState } from 'react'; 2 | import { EventType } from '../utils/events/types'; 3 | 4 | export type DateFilter = 'start_date' | 'registration_start'; 5 | export type EventTypeFilter = EventType | 'alle'; 6 | 7 | export interface IEventFilterContext { 8 | selectedDateFilter: DateFilter; 9 | selectedEventTypeFilter: EventTypeFilter; 10 | setSelectedDateFilter: React.Dispatch>; 11 | setSelectedEventTypeFilter: React.Dispatch< 12 | React.SetStateAction 13 | >; 14 | } 15 | 16 | export const EventFilterContext = createContext({ 17 | selectedDateFilter: 'start_date', 18 | selectedEventTypeFilter: 'alle', 19 | setSelectedDateFilter: () => {}, 20 | setSelectedEventTypeFilter: () => {}, 21 | }); 22 | 23 | export const useEventFilterContext = () => useContext(EventFilterContext); 24 | 25 | export default function EventFilterProvider({ 26 | children, 27 | }: { 28 | children: React.ReactNode; 29 | }) { 30 | const [selectedDateFilter, setSelectedDateFilter] = 31 | useState('registration_start'); 32 | const [selectedEventTypeFilter, setSelectedEventTypeFilter] = 33 | useState('alle'); 34 | 35 | return ( 36 | 44 | {children} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /frontend/components/PrimaryToggle.tsx: -------------------------------------------------------------------------------- 1 | interface IProps { 2 | onClick?: () => void; 3 | children: React.ReactNode; 4 | selected?: boolean; 5 | color?: LegalColors; 6 | } 7 | 8 | export default function PrimaryButton({ 9 | onClick, 10 | children, 11 | selected, 12 | color, 13 | }: IProps) { 14 | const bgColor = getBgColor(color); 15 | const textColor = getTextColor(color); 16 | return ( 17 | 27 | ); 28 | } 29 | 30 | type LegalColors = 31 | | 'owSecondary' 32 | | 'event-bedpres' 33 | | 'event-kurs' 34 | | 'event-sosialt'; 35 | 36 | function getBgColor(color?: LegalColors): string { 37 | switch (color) { 38 | case 'owSecondary': 39 | return 'bg-owSecondary'; 40 | case 'event-bedpres': 41 | return 'bg-event-bedpres'; 42 | case 'event-kurs': 43 | return 'bg-event-kurs'; 44 | case 'event-sosialt': 45 | return 'bg-event-sosialt'; 46 | default: 47 | return 'bg-owSecondary'; 48 | } 49 | } 50 | 51 | function getTextColor(color?: LegalColors): string { 52 | switch (color) { 53 | case 'owSecondary': 54 | return 'text-owSecondary'; 55 | case 'event-bedpres': 56 | return 'text-event-bedpres'; 57 | case 'event-kurs': 58 | return 'text-event-kurs'; 59 | case 'event-sosialt': 60 | return 'text-event-sosialt'; 61 | default: 62 | return 'text-owSecondary'; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /frontend/utils/date/formatDate.ts: -------------------------------------------------------------------------------- 1 | export default function formatDate(date: Date | string) { 2 | if (typeof date === 'string') { 3 | date = new Date(date); 4 | } 5 | const now = new Date(); 6 | const deltaDays = date.getDate() - now.getDate(); 7 | if (deltaDays === 0) { 8 | return 'i dag'; 9 | } 10 | if (deltaDays === 1) { 11 | return 'i morgen'; 12 | } 13 | const dayString = date.toLocaleDateString('nb-NO', { 14 | weekday: 'long', 15 | day: 'numeric', 16 | month: 'long', 17 | }); 18 | return dayString; 19 | } 20 | 21 | export function formatRemainingTime(date: Date | string): string { 22 | if (typeof date === 'string') { 23 | date = new Date(date); 24 | } 25 | const now = new Date(); 26 | const deltaTime = Math.round((date.getTime() - now.getTime()) / 1000); 27 | if (deltaTime < 60) { 28 | return `${deltaTime} sekunder`; 29 | } 30 | if (deltaTime < 3600) { 31 | return `${Math.round(deltaTime / 60)} minutter`; 32 | } 33 | if (deltaTime < 86400) { 34 | return `${Math.round(deltaTime / 3600)} timer`; 35 | } 36 | if (deltaTime < 172_800) { 37 | return 'I morgen'; 38 | } 39 | if (deltaTime < 604_800) { 40 | const day = date.toLocaleDateString('nb-NO', { weekday: 'long' }); 41 | return `På ${day}`; 42 | } 43 | return `${Math.round(deltaTime / 86_400)} dager`; 44 | } 45 | 46 | export function getEventTime(date: Date | string): string { 47 | if (typeof date === 'string') { 48 | date = new Date(date); 49 | } 50 | const dayString = date.toLocaleTimeString('nb-NO', { 51 | hour: 'numeric', 52 | minute: 'numeric', 53 | }); 54 | return dayString; 55 | } 56 | -------------------------------------------------------------------------------- /frontend/pages/api/sms/otp/generate.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import Twilio from 'twilio'; 3 | import { generateEncryptedOtp } from '../../../../utils/otp'; 4 | 5 | const accountSid = process.env.TWILIO_ACCOUNT_SID; 6 | const authToken = process.env.TWILIO_AUTH_TOKEN; 7 | 8 | var twilio = new (Twilio as any)(accountSid, authToken); 9 | 10 | const sender = 'Bedpres Bot'; 11 | 12 | export default async function handler( 13 | req: NextApiRequest, 14 | res: NextApiResponse 15 | ) { 16 | if (req.method !== 'POST') { 17 | return res.status(405).json({ error: 'Method not allowed' }); 18 | } 19 | 20 | const { phoneNumber } = req.body; 21 | if (!phoneNumber) { 22 | return res.status(400).json({ error: 'Missing parameters' }); 23 | } 24 | 25 | if (!accountSid || !authToken) { 26 | return res.status(500).json({ error: 'Missing environment variables' }); 27 | } 28 | 29 | if (phoneNumber !== process.env.DEV_PHONE_NUMBER) { 30 | return res.status(401).json({ 31 | error: 32 | "This feature is in development. To prevent expensive SMS costs, I've only enabled it for my phone number", 33 | }); 34 | } 35 | 36 | if (phoneNumber.length !== 8) { 37 | return res.status(400).json({ error: 'Invalid phone number' }); 38 | } 39 | 40 | const paddedPhoneNumber = phoneNumber.includes('+') 41 | ? phoneNumber 42 | : `+47${phoneNumber}`; 43 | 44 | const { cipher, code } = await generateEncryptedOtp(phoneNumber); 45 | 46 | await twilio.messages.create({ 47 | to: paddedPhoneNumber, 48 | from: sender, 49 | body: `${code} er din kode for Bedpres Bot`, 50 | }); 51 | 52 | return res.status(200).json({ cipher }); 53 | } 54 | -------------------------------------------------------------------------------- /backend/main_ad_manager.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Tuple 2 | from config.types import Ad, Subscriber 3 | from sms import send_sms 4 | from db import Database 5 | import logger 6 | 7 | SubscriberAdPair = Tuple[Subscriber, Ad] 8 | 9 | def subscriber_has_received_ad(subscriber: Subscriber, ad: Ad) -> bool: 10 | return ad["key"] in subscriber["ads_received"] 11 | 12 | def find_ad_for_subscriber(subscriber: Subscriber, ads: List[Ad]) -> Optional[Ad]: 13 | for ad in ads: 14 | if not subscriber_has_received_ad(subscriber, ad): 15 | return ad 16 | return None 17 | 18 | def send_ad(data, context) -> str: 19 | logger.info("********* SENDING ADS... *********") 20 | 21 | database = Database() 22 | database.connect() 23 | 24 | subscribers = database.get_subscribers_for_ads() 25 | ads = database.get_active_ads() 26 | 27 | logger.info(f"Found {len(subscribers)} subscribers and {len(ads)} active ads") 28 | 29 | # Python typing is dumb and doesn't understand that 'if s2a[1] is not None' means the ad is no longer nullable (Optional[Ad]). So we need this temp variable and manually cast it. 30 | sub_ad_pairs_optionalad = [(subscriber, find_ad_for_subscriber(subscriber, ads)) for subscriber in subscribers] 31 | sub_ad_pairs: List[SubscriberAdPair] = [s2a for s2a in sub_ad_pairs_optionalad if s2a[1] is not None] # type: ignore 32 | 33 | logger.info(f"Sending ad to {len(sub_ad_pairs)} subscribers") 34 | 35 | for sub_ad_pair in sub_ad_pairs: 36 | subscriber = sub_ad_pair[0] 37 | ad = sub_ad_pair[1] 38 | send_sms(ad["text"], subscriber["phone_number"]) 39 | database.add_new_ad_received(ad["key"], subscriber) 40 | 41 | database.disconnect() 42 | 43 | return 'OK' 44 | -------------------------------------------------------------------------------- /frontend/providers/EnvironmentContext.tsx: -------------------------------------------------------------------------------- 1 | // A context provider for environment variables exposed to the client. These are opaque to the end-user. Secret 2 | // environment variables such as API keys must be used in server components and accessed directly via process.env. 3 | 4 | 'use client'; 5 | 6 | import React, { createContext, useEffect, useState } from 'react'; 7 | 8 | export interface IEnvironmentContext { 9 | OW_AUTHORIZE_URL: string; 10 | OW_TOKEN_URL: string; 11 | CLIENT_ID: string; 12 | REDIRECT_URI: string; 13 | } 14 | 15 | export const EnvironmentContext = createContext({ 16 | OW_AUTHORIZE_URL: '', 17 | OW_TOKEN_URL: '', 18 | CLIENT_ID: '', 19 | REDIRECT_URI: '', 20 | }); 21 | 22 | export default function EnvironmentProvider({ 23 | children, 24 | }: { 25 | children: React.ReactNode; 26 | }) { 27 | const [environment] = useState({ 28 | OW_AUTHORIZE_URL: process.env.NEXT_PUBLIC_OW_AUTHORIZE_URL || '', 29 | OW_TOKEN_URL: process.env.NEXT_PUBLIC_OW_TOKEN_URL || '', 30 | CLIENT_ID: process.env.NEXT_PUBLIC_CLIENT_ID || '', 31 | REDIRECT_URI: process.env.NEXT_PUBLIC_REDIRECT_URI || '', 32 | }); 33 | 34 | useEffect(() => { 35 | if (environment.OW_AUTHORIZE_URL === '') { 36 | console.error('OW_AUTHORIZE_URL is not set'); 37 | } 38 | if (environment.OW_TOKEN_URL === '') { 39 | console.error('OW_TOKEN_URL is not set'); 40 | } 41 | if (environment.CLIENT_ID === '') { 42 | console.error('CLIENT_ID is not set'); 43 | } 44 | if (environment.REDIRECT_URI === '') { 45 | console.error('REDIRECT_URI is not set'); 46 | } 47 | }, [environment]); 48 | 49 | return ( 50 | 51 | {children} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /backend/scheduler.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from typing import Any 4 | from google.cloud import tasks_v2 5 | from google.protobuf import duration_pb2, timestamp_pb2 # type: ignore 6 | from config.env import SCHEDULER_LOCATION, SCHEDULER_PROJECT_ID, SCHEDULER_QUEUE, SERVICE_ACCOUNT_EMAIL, SMS_SEND_URL 7 | import logger 8 | 9 | from enums import MessageType 10 | 11 | client = tasks_v2.CloudTasksClient() 12 | 13 | project = SCHEDULER_PROJECT_ID 14 | queue = SCHEDULER_QUEUE 15 | location = SCHEDULER_LOCATION 16 | service_account_email = SERVICE_ACCOUNT_EMAIL 17 | url = SMS_SEND_URL 18 | 19 | if not project or not queue or not location or not service_account_email or not url: 20 | raise ValueError("Missing environment variables") 21 | 22 | parent = client.queue_path(project, location, queue) 23 | 24 | def schedule_external_sms_sender(event_ids: list[int], message_type: MessageType, time_to_send: int) -> Any: 25 | logger.info("schedule_external_sms_sender...") 26 | payload: Any = { 'event_ids': event_ids, 'message_type': message_type.value } 27 | payload = json.dumps(payload) 28 | converted_payload = payload.encode() 29 | d = datetime.datetime.utcnow() + datetime.timedelta(seconds=time_to_send) 30 | 31 | timestamp = timestamp_pb2.Timestamp() 32 | timestamp.FromDatetime(d) 33 | duration = duration_pb2.Duration() 34 | duration.FromSeconds(600) 35 | 36 | task = { 37 | "http_request": { 38 | "http_method": tasks_v2.HttpMethod.POST, 39 | "url": url, 40 | "headers": {"Content-type": "application/json"}, 41 | "body": converted_payload, 42 | "oidc_token": { 43 | "service_account_email": service_account_email, 44 | "audience": url, 45 | }, 46 | }, 47 | "schedule_time": timestamp, 48 | "dispatch_deadline": duration 49 | } 50 | 51 | response = client.create_task(request={"parent": parent, "task": task}) 52 | 53 | return response 54 | -------------------------------------------------------------------------------- /frontend/app/dashboard/myEvents.tsx: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | import database from '../../database'; 3 | import EventList from './myEventsList'; 4 | 5 | export default async function MyEvents() { 6 | try { 7 | const events = await database.fetchEvents(); 8 | 9 | const nextHeaders = headers(); 10 | const token = nextHeaders.get('x-ow-token'); 11 | if (!token) 12 | return ( 13 |

14 | Kunne ikke hente brukerinformasjon. OW token: {token} 15 |

16 | ); 17 | 18 | const myEventIds = await fetchMyEventIds(token); 19 | const eventsImAttending = events.filter((event) => 20 | myEventIds.includes(event.id) 21 | ); 22 | if (eventsImAttending.length === 0) 23 | return ( 24 |

25 | Du er ikke meldt på noen arrangementer... 26 |

27 | ); 28 | 29 | return ; 30 | } catch (err) { 31 | console.error(err); 32 | return ( 33 | <> 34 |

Kunne ikke hente arrangementer

35 | 36 | ); 37 | } 38 | } 39 | 40 | async function fetchMyEventIds(token: string) { 41 | const pages = Array.from({ length: 3 }, (_, i) => i + 1); 42 | const urls = pages.map( 43 | (page) => 44 | `https://old.online.ntnu.no/api/v1/event/attendance-events/?ordering=-registration_start&page=${page}` 45 | ); 46 | const responses = await Promise.all( 47 | urls.map(async (url) => 48 | fetch(url, { 49 | headers: { 50 | Authorization: `Bearer ${token}`, 51 | }, 52 | }) 53 | ) 54 | ); 55 | if (responses.some((res) => !res.ok)) 56 | throw new Error('Kunne ikke hente arrangementer'); 57 | 58 | const data = await Promise.all(responses.map((res) => res.json())); 59 | 60 | return data 61 | .reduce((acc, val) => acc.concat(val.results), []) 62 | .filter((event: any) => event.is_attendee) 63 | .map((event: any) => event.id); 64 | } 65 | -------------------------------------------------------------------------------- /frontend/app/dashboard/myEventsList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import formatDate, { getEventTime } from '../../utils/date/formatDate'; 5 | import { IEvent } from '../../database/models/Event'; 6 | import capitalize from '../../utils/capitalize'; 7 | import eventTypeToIcon from '../../utils/events/eventTypeToIcon'; 8 | import PrimaryButton from '../../components/PrimaryToggle'; 9 | import Image from 'next/image'; 10 | 11 | interface IProps { 12 | events: IEvent[]; 13 | } 14 | 15 | export default function EventList({ events }: IProps) { 16 | return ( 17 |
18 | {events.map((event: IEvent) => ( 19 |
20 |

21 | {capitalize(formatDate(event['start_date']))}{' '} 22 | {getEventTime(event['start_date'])} 23 |

24 |
25 | 26 |
27 |
28 | ))} 29 |
30 | ); 31 | } 32 | 33 | function Event({ event }: { event: IEvent }) { 34 | return ( 35 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /backend/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import pytz # type: ignore 3 | from config.types import EventWithAttendees, Subscriber 4 | 5 | timezone = pytz.timezone('Europe/Oslo') 6 | 7 | def date_is_in_the_future(date: str) -> bool: 8 | formatted_date = datetime.fromisoformat(date) 9 | current_date = datetime.now(timezone) 10 | return formatted_date > current_date 11 | 12 | def get_delay_until_five_minutes_before_event(date: str) -> int: 13 | now = datetime.now(timezone) 14 | event_time = now.strptime(date, "%Y-%m-%dT%H:%M:%S%z") 15 | run_at = event_time - timedelta(minutes=5) 16 | delay = int((run_at - now).total_seconds()) 17 | return delay 18 | 19 | def get_delay_until_one_hour_before_event(date: str) -> int: 20 | now = datetime.now(timezone) 21 | event_time = now.strptime(date, "%Y-%m-%dT%H:%M:%S%z") 22 | run_at = event_time - timedelta(hours=1) 23 | delay = int((run_at - now).total_seconds()) 24 | return delay 25 | 26 | def get_current_date() -> datetime: 27 | return datetime.now(timezone) 28 | 29 | def format_phone_number(phone_number: str) -> str: 30 | '''Formats phone number to the format +47XXXXXXXX which is required by Twilio. 31 | This is necessary because the phone numbers from OW are just plain text, and may be written 32 | in different formats. 33 | ''' 34 | phone_number = phone_number.replace(' ', '') 35 | if phone_number[0] == '+': 36 | return phone_number 37 | return f'+47{phone_number}' 38 | 39 | def format_full_name(subscriber: Subscriber) -> str: 40 | '''Formats full name same way it's done in OW. 41 | See get_full_name in https://github.com/dotkom/onlineweb4/blob/main/apps/authentication/models.py 42 | ''' 43 | ow_data = subscriber["ow"] 44 | full_name = "%s %s" % (ow_data["first_name"], ow_data["last_name"]) 45 | return full_name.strip() 46 | 47 | def subscriber_is_attending_event(subscriber: Subscriber, event: EventWithAttendees) -> bool: 48 | if "ow" not in subscriber: 49 | return False 50 | full_name = format_full_name(subscriber) 51 | users = event['attendees'] 52 | return full_name in [user["full_name"] for user in users] 53 | -------------------------------------------------------------------------------- /frontend/components/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useContext } from 'react'; 4 | import Image from 'next/image'; 5 | import pkceChallenge from 'pkce-challenge'; 6 | import { EnvironmentContext } from '../providers/EnvironmentContext'; 7 | 8 | interface IPkceChallenge { 9 | codeChallenge: string; 10 | codeVerifier: string; 11 | } 12 | 13 | function generateNewPkceChallenge() { 14 | const { code_challenge: codeChallenge, code_verifier: codeVerifier } = 15 | pkceChallenge(); 16 | 17 | if (typeof window !== 'undefined') { 18 | window.localStorage.setItem('code_verifier', codeVerifier); 19 | window.localStorage.setItem('code_challenge', codeChallenge); 20 | } 21 | 22 | return { codeChallenge, codeVerifier }; 23 | } 24 | 25 | function getPkceChallengeFromLocalStorage(): IPkceChallenge | undefined { 26 | if (typeof window === 'undefined') return undefined; 27 | 28 | const codeVerifier = window.localStorage.getItem('code_verifier'); 29 | const codeChallenge = window.localStorage.getItem('code_challenge'); 30 | 31 | if (!codeVerifier || !codeChallenge) return undefined; 32 | 33 | return { codeChallenge, codeVerifier }; 34 | } 35 | 36 | export default function LoginButton() { 37 | const environment = useContext(EnvironmentContext); 38 | 39 | const logIn = () => { 40 | const { codeChallenge } = generateNewPkceChallenge(); 41 | 42 | const url = new URL(environment.OW_AUTHORIZE_URL); 43 | url.searchParams.append('client_id', environment.CLIENT_ID); 44 | url.searchParams.append('redirect_uri', environment.REDIRECT_URI); 45 | url.searchParams.append('response_type', 'code'); 46 | url.searchParams.append('scope', 'openid profile email'); 47 | url.searchParams.append('code_challenge', codeChallenge); 48 | url.searchParams.append('code_challenge_method', 'S256'); 49 | url.searchParams.append('response_mode', 'query'); 50 | 51 | window.location.href = url.toString(); 52 | }; 53 | 54 | return ( 55 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Backend 2 | 3 | ## Setup instructions 4 | 5 | 0. This assumes you have a working Python 3.9+ environment with pip installed. 6 | 1. Create a virtual environment: `python3 -m venv env` 7 | 2. Activate the virtual environment: `source env/bin/activate` 8 | 3. Install the dependencies: `pip3 install -r requirements.txt` 9 | 4. Create a .env file. See .env.example for an example. 10 | 11 | ## File structure 12 | 13 | The backend runs on Google Cloud as serverless functions. This is really nice because it means the server costs are literally zero (Google Cloud allows 2 million free requests per month). There are three main files: 14 | 15 | - `main_scraper.py` - This retrieves information from OW about new events. I initially named it scraper because I thought I had to scrape the OW website, but OW's API is fully open so we just send web requests to the API instead. However, I have used the term _scraper_ throughout and never bothererd changing it. In Google Cloud, this is a scheduled cron function that runs every night at 3am. 16 | - `main_sms_sender.py` - This sends out a given message as SMS to all subscribers. This is an HTTP triggered event in Google Cloud, i.e. it is manually triggered by an authenticated web request. Google Cloud's HTTP functions are based on Flask. 17 | - `main_sms_scheduler.py` - This checks whether there are events with registration_start in the next 24 hours and if so, schedules an SMS sender task 5 minutes before registration_start. In Google Cloud, this is a scheduled cron function that runs every night at 3:30am. 18 | - `main_ad_manager.py` - This retrieves all subscribers who are supposed to receive ads (there is a boolean field _should_receive_ads_ in the users database) and finds a suitable ad based on a priority list. Then, it sends an SMS to all the users whom has a suitable ad. The script is a scheduled cron function which runs the 15th of every month. 19 | 20 | Additionally, there's a main function called `api.py` which is a very simple Flask server that I run on a Linode server instead of a serverless Google Cloud function. The server listens to incoming subscribe/unsubscribe SMS, and updates the database accordingly. When the frontend is fully implemented it will handle new signups, and this server file can be removed. 21 | 22 | The rest of the files are helper functions and classes. They should be named quite self-explanatory. 23 | -------------------------------------------------------------------------------- /frontend/app/bingo/game.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createContext, useContext, useEffect, useState } from 'react'; 4 | import BingoBoard from './board'; 5 | import BingoControls from './controls'; 6 | import { getNewBingoTerms, isBingo } from './utils'; 7 | import Confetti from 'react-confetti'; 8 | 9 | export type BingoContextType = { 10 | cells: string[][]; 11 | selected: boolean[][]; 12 | toggleCell: (row: number, col: number) => void; 13 | restart: () => void; 14 | }; 15 | 16 | export const BingoContext = createContext({ 17 | cells: [], 18 | selected: [], 19 | toggleCell: () => {}, 20 | restart: () => {}, 21 | }); 22 | 23 | export const useBingoContext = () => { 24 | return useContext(BingoContext); 25 | }; 26 | 27 | export default function BingoGame() { 28 | const [cells, setCells] = useState(getNewBingoTerms()); 29 | const [selected, setSelected] = useState( 30 | generateFalseBoolean2DArray() 31 | ); 32 | const [hasBingo, setHasBingo] = useState(false); 33 | 34 | // Because we randomize the board on render, we must do this to ensure client and server render the same thing 35 | const [hydrated, setHydrated] = useState(false); 36 | useEffect(() => { 37 | setHydrated(true); 38 | }, []); 39 | if (!hydrated) { 40 | // Returns null on first render, so the client and server match 41 | return null; 42 | } 43 | const toggleCell = (row: number, col: number) => { 44 | if (hasBingo) return; 45 | const newSelected = [...selected]; 46 | newSelected[row][col] = !newSelected[row][col]; 47 | setSelected(newSelected); 48 | if (isBingo(newSelected)) { 49 | setHasBingo(true); 50 | } 51 | }; 52 | 53 | const restart = () => { 54 | setSelected(generateFalseBoolean2DArray()); 55 | setCells(getNewBingoTerms()); 56 | setHasBingo(false); 57 | }; 58 | 59 | return ( 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | } 67 | 68 | function generateFalseBoolean2DArray() { 69 | const rows = 4; 70 | const cols = 4; 71 | const output = new Array(rows); 72 | for (let i = 0; i < rows; i++) { 73 | output[i] = new Array(cols).fill(false); 74 | } 75 | return output; 76 | } 77 | -------------------------------------------------------------------------------- /frontend/database/models/OWData.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { mongo } from 'mongoose'; 2 | 3 | export const OWDataSchema = new mongoose.Schema({ 4 | _id: { 5 | type: mongoose.Schema.Types.String, 6 | unique: true, 7 | required: true, 8 | index: true, 9 | }, 10 | id: { type: mongoose.Schema.Types.Number, unique: true, required: true }, 11 | username: { 12 | type: mongoose.Schema.Types.String, 13 | unique: true, 14 | required: true, 15 | }, 16 | nickname: mongoose.Schema.Types.String, 17 | first_name: { 18 | type: mongoose.Schema.Types.String, 19 | unique: true, 20 | required: true, 21 | }, 22 | last_name: { 23 | type: mongoose.Schema.Types.String, 24 | unique: true, 25 | required: true, 26 | }, 27 | phone_number: mongoose.Schema.Types.String, 28 | online_mail: mongoose.Schema.Types.String, 29 | address: mongoose.Schema.Types.String, 30 | zip_code: mongoose.Schema.Types.String, 31 | email: mongoose.Schema.Types.String, 32 | website: mongoose.Schema.Types.String, 33 | github: mongoose.Schema.Types.String, 34 | linkedin: mongoose.Schema.Types.String, 35 | ntnu_username: mongoose.Schema.Types.String, 36 | field_of_study: { 37 | type: mongoose.Schema.Types.String, 38 | unique: true, 39 | required: true, 40 | }, 41 | year: { type: mongoose.Schema.Types.String, unique: true, required: true }, 42 | bio: { type: mongoose.Schema.Types.String, unique: true, required: true }, 43 | positions: mongoose.Schema.Types.Array, 44 | special_positions: mongoose.Schema.Types.Array, 45 | image: { type: mongoose.Schema.Types.String, unique: true, required: true }, 46 | started_date: { type: mongoose.Schema.Types.String, required: true }, 47 | }); 48 | 49 | export interface IOWData { 50 | _id?: string; 51 | id: number; 52 | username: string; 53 | nickname?: string; 54 | first_name: string; 55 | last_name: string; 56 | phone_number?: string; 57 | online_mail?: string; 58 | address?: string; 59 | zip_code?: string; 60 | email?: string; 61 | website?: string; 62 | github?: string; 63 | linkedin?: string; 64 | ntnu_username?: string; 65 | field_of_study: string; 66 | year: string; 67 | bio: string; 68 | positions?: string[]; 69 | special_positions?: string[]; 70 | image: string; 71 | started_date: string; 72 | } 73 | 74 | export default mongoose.model('OWData', OWDataSchema); 75 | -------------------------------------------------------------------------------- /.github/workflows/gc-storage-sync.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Zip folder 16 | working-directory: backend 17 | run: | 18 | mv main_ad_manager.py main.py 19 | zip -r admanager-function-source.zip ./ 20 | mv main.py main_ad_manager.py 21 | mv main_scraper.py main.py 22 | zip -r scraper-function-source.zip ./ 23 | mv main.py main_scraper.py 24 | mv main_sms_sender.py main.py 25 | zip -r smssender-function-source.zip ./ 26 | mv main.py main_sms_sender.py 27 | mv main_sms_scheduler.py main.py 28 | zip -r smsscheduler-function-source.zip ./ 29 | mv main.py main_sms_scheduler.py 30 | 31 | - name: Create zip artifact 32 | uses: actions/upload-artifact@v3 33 | with: 34 | name: gc-function-source.zip 35 | path: | 36 | backend/admanager-function-source.zip 37 | backend/scraper-function-source.zip 38 | backend/smssender-function-source.zip 39 | backend/smsscheduler-function-source.zip 40 | 41 | - name: GC Auth 42 | uses: google-github-actions/auth@v0 43 | with: 44 | credentials_json: '${{ secrets.google_service_account_credentials }}' 45 | 46 | - name: Upload ad manager zip to GC 47 | uses: google-github-actions/upload-cloud-storage@v0 48 | with: 49 | path: 'backend/admanager-function-source.zip' 50 | destination: 'bedpresbot' 51 | 52 | - name: Upload scraper zip to GC 53 | uses: google-github-actions/upload-cloud-storage@v0 54 | with: 55 | path: 'backend/scraper-function-source.zip' 56 | destination: 'bedpresbot' 57 | 58 | - name: Upload sms sender zip to GC 59 | uses: google-github-actions/upload-cloud-storage@v0 60 | with: 61 | path: 'backend/smssender-function-source.zip' 62 | destination: 'bedpresbot' 63 | 64 | - name: Upload sms scheduler zip to GC 65 | uses: google-github-actions/upload-cloud-storage@v0 66 | with: 67 | path: 'backend/smsscheduler-function-source.zip' 68 | destination: 'bedpresbot' 69 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | appnope==0.1.2 2 | argon2-cffi==21.1.0 3 | attrs==21.2.0 4 | backcall==0.2.0 5 | backports.entry-points-selectable==1.1.0 6 | bleach==4.1.0 7 | cachetools==5.2.0 8 | certifi==2022.6.15 9 | cffi==1.15.0 10 | charset-normalizer==2.1.1 11 | click==8.1.3 12 | DateTime==4.5 13 | debugpy==1.5.1 14 | decorator==5.1.0 15 | defusedxml==0.7.1 16 | distlib==0.3.3 17 | dnspython==2.2.1 18 | entrypoints==0.3 19 | filelock==3.3.1 20 | Flask==2.2.2 21 | google-api-core==2.10.1 22 | google-auth==2.11.1 23 | google-cloud-appengine-logging==1.1.4 24 | google-cloud-audit-log==0.2.4 25 | google-cloud-core==2.3.2 26 | google-cloud-logging==3.2.2 27 | google-cloud-tasks==2.10.2 28 | googleapis-common-protos==1.56.4 29 | grpc-google-iam-v1==0.12.4 30 | grpcio==1.49.1 31 | grpcio-status==1.49.1 32 | gunicorn==20.1.0 33 | idna==3.3 34 | importlib-metadata==4.12.0 35 | ipykernel==6.4.2 36 | ipython==7.28.0 37 | ipython-genutils==0.2.0 38 | itsdangerous==2.1.2 39 | jedi==0.18.0 40 | Jinja2==3.0.2 41 | jsonschema==4.1.2 42 | jupyter-client==7.0.6 43 | jupyter-core==4.8.1 44 | jupyterlab-pygments==0.1.2 45 | MarkupSafe==2.1.1 46 | matplotlib-inline==0.1.3 47 | mccabe==0.7.0 48 | mistune==0.8.4 49 | mypy==0.982 50 | mypy-extensions==0.4.3 51 | mypy-lang==0.5.0 52 | nbclient==0.5.4 53 | nbconvert==6.2.0 54 | nbformat==5.1.3 55 | nest-asyncio==1.5.1 56 | notebook==6.4.5 57 | packaging==21.0 58 | pandocfilters==1.5.0 59 | parso==0.8.2 60 | pexpect==4.8.0 61 | pickleshare==0.7.5 62 | platformdirs==2.4.0 63 | prometheus-client==0.11.0 64 | prompt-toolkit==3.0.21 65 | proto-plus==1.22.1 66 | protobuf==4.21.6 67 | ptyprocess==0.7.0 68 | pyasn1==0.4.8 69 | pyasn1-modules==0.2.8 70 | pycodestyle==2.9.1 71 | pycparser==2.20 72 | pyflakes==2.5.0 73 | Pygments==2.10.0 74 | PyJWT==2.4.0 75 | pymongo==4.2.0 76 | pyparsing==3.0.1 77 | pyrsistent==0.18.0 78 | python-dateutil==2.8.2 79 | python-dotenv==0.21.0 80 | pytz==2022.2.1 81 | pyzmq==22.3.0 82 | requests==2.28.1 83 | rsa==4.9 84 | Send2Trash==1.8.0 85 | six==1.16.0 86 | terminado==0.12.1 87 | testpath==0.5.0 88 | tomli==2.0.1 89 | tornado==6.1 90 | traitlets==5.1.1 91 | twilio==7.14.0 92 | types-pytz==2022.4.0.0 93 | typing_extensions==4.4.0 94 | urllib3==1.26.12 95 | virtualenv==20.9.0 96 | wcwidth==0.2.5 97 | webencodings==0.5.1 98 | Werkzeug==2.2.2 99 | zipp==3.8.1 100 | zope.interface==5.4.0 101 | -------------------------------------------------------------------------------- /backend/main_sms_scheduler.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | from scheduler import schedule_external_sms_sender 3 | from db import Database, date_to_events 4 | from enums import MessageType 5 | from utils import get_delay_until_five_minutes_before_event, get_delay_until_one_hour_before_event 6 | import logger 7 | 8 | def get_todays_events() -> Tuple[ date_to_events, date_to_events, date_to_events ]: 9 | database = Database() 10 | database.connect() 11 | register_events = database.get_todays_register_events_from_database() 12 | unattend_events = database.get_todays_unattend_events_from_database() 13 | start_events = database.get_todays_start_events_from_database() 14 | database.disconnect() 15 | return register_events, unattend_events, start_events 16 | 17 | def schedule_sms_for_todays_events(data, context) -> str: 18 | logger.info("********* SCHEDULING SMS FOR TODAY... *********") 19 | register_events, unattend_events, start_events = get_todays_events() 20 | logger.info(f"Found {len(register_events)} register events, {len(unattend_events)} unattend events and {len(start_events)} start events to schedule SMS for") 21 | for date in register_events: 22 | time_to_send = get_delay_until_five_minutes_before_event(date) 23 | events = register_events[date] 24 | event_ids = [event['id'] for event in events] 25 | logger.info(f"Sending sms for register events {[event['title'] for event in events]} at {date} in {time_to_send} seconds") 26 | schedule_external_sms_sender(event_ids, MessageType.REGISTRATION_START, time_to_send) 27 | for date in unattend_events: 28 | time_to_send = get_delay_until_one_hour_before_event(date) 29 | events = unattend_events[date] 30 | event_ids = [event['id'] for event in events] 31 | logger.info(f"Sending sms for unattend events {[event['title'] for event in events]} at {date} in {time_to_send} seconds") 32 | schedule_external_sms_sender(event_ids, MessageType.UNATTEND, time_to_send) 33 | for date in start_events: 34 | time_to_send = get_delay_until_one_hour_before_event(date) 35 | events = start_events[date] 36 | event_ids = [event['id'] for event in events] 37 | logger.info(f"Sending sms for start events {[event['title'] for event in events]} at {date} in {time_to_send} seconds") 38 | schedule_external_sms_sender(event_ids, MessageType.EVENT_START, time_to_send) 39 | 40 | return 'OK' 41 | -------------------------------------------------------------------------------- /frontend/database/models/Event.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { EventIndex } from '../../utils/events/types'; 3 | import { IImage, ImageSchema } from './Image'; 4 | 5 | const EventSchema = new mongoose.Schema({ 6 | _id: { type: mongoose.Schema.Types.ObjectId }, 7 | id: { type: mongoose.Schema.Types.Number, unique: true, required: true }, 8 | title: { type: mongoose.Schema.Types.String, required: true }, 9 | slug: { type: mongoose.Schema.Types.String, required: true }, 10 | ingress: { type: mongoose.Schema.Types.String, required: true }, 11 | ingress_short: { type: mongoose.Schema.Types.String, required: true }, 12 | description: { type: mongoose.Schema.Types.String, required: true }, 13 | start_date: { type: mongoose.Schema.Types.String, required: true }, 14 | end_date: { type: mongoose.Schema.Types.String, required: true }, 15 | location: { type: mongoose.Schema.Types.String, required: true }, 16 | event_type: { type: mongoose.Schema.Types.Number, required: true }, 17 | event_type_display: { type: mongoose.Schema.Types.String, required: true }, 18 | organizer: mongoose.Schema.Types.Number, 19 | images: [ImageSchema], 20 | companies: [mongoose.Schema.Types.String], 21 | is_attendance_event: { type: mongoose.Schema.Types.Boolean, required: true }, 22 | max_capacity: { type: mongoose.Schema.Types.Number, required: true }, 23 | number_of_seats_taken: { type: mongoose.Schema.Types.Number, required: true }, 24 | registration_start: { type: mongoose.Schema.Types.String, required: true }, 25 | registration_end: { type: mongoose.Schema.Types.String, required: true }, 26 | unattend_deadline: { type: mongoose.Schema.Types.String, required: true }, 27 | number_on_waitlist: mongoose.Schema.Types.Number, 28 | rule_bundle: { type: [mongoose.Schema.Types.Number], required: true }, 29 | }); 30 | 31 | export interface IEvent { 32 | _id?: mongoose.Types.ObjectId; 33 | id: number; 34 | title: string; 35 | slug: string; 36 | ingress: string; 37 | ingress_short: string; 38 | description: string; 39 | start_date: string; 40 | end_date: string; 41 | location: string; 42 | event_type: EventIndex; 43 | event_type_display: string; 44 | organizer?: number; 45 | images?: IImage[]; 46 | companies?: string[]; 47 | is_attendance_event: boolean; 48 | max_capacity: number; 49 | number_of_seats_taken: number; 50 | registration_start: string; 51 | registration_end: string; 52 | unattend_deadline: string; 53 | number_on_waitlist?: number; 54 | rule_bundle: number[]; 55 | } 56 | 57 | export default mongoose.model('Event', EventSchema); 58 | -------------------------------------------------------------------------------- /backend/main_sms_sender.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | from enums import MessageType 3 | from online import get_attendees_for_event 4 | from sms import format_message_for_events, send_sms 5 | from db import Database 6 | from config.types import Event, EventWithAttendees, Subscriber 7 | from utils import subscriber_is_attending_event 8 | import logger 9 | 10 | SubscriberEventsPair = Tuple[Subscriber, List[EventWithAttendees]] 11 | 12 | def subscriber_should_receive_message(subscriber: Subscriber, event: EventWithAttendees, message_type: MessageType) -> bool: 13 | if (message_type == MessageType.REGISTRATION_START): 14 | # TODO: Once we have a web interface, we should only send SMS to people who have registered for the event 15 | return True 16 | elif (message_type == MessageType.UNATTEND or message_type == MessageType.EVENT_START): 17 | return subscriber_is_attending_event(subscriber, event) 18 | else: 19 | raise Exception(f"Message type {message_type} not yet supported") 20 | 21 | def add_attendees_to_event(event: Event) -> EventWithAttendees: 22 | attendees = get_attendees_for_event(event) 23 | return {**event, 'attendees': attendees} # type: ignore 24 | 25 | def sms_endpoint(data) -> str: 26 | logger.info("********* SMS ENDPOINT CALLED... *********") 27 | try: 28 | database = Database() 29 | database.connect() 30 | 31 | event_ids = data.json['event_ids'] 32 | message_type = MessageType(data.json['message_type']) 33 | 34 | subscribers = database.get_all_subscribers() # TODO: Change this to database.get_subscribers_for_events(event_ids) 35 | events = database.get_events_by_id(event_ids) 36 | 37 | events_with_attendees = [add_attendees_to_event(event) for event in events] 38 | 39 | subscriber_event_pairs: List[SubscriberEventsPair] = [( 40 | subscriber, 41 | [event for event in events_with_attendees if subscriber_should_receive_message(subscriber, event, message_type)]) 42 | for subscriber in subscribers] 43 | 44 | subscriber_event_pairs = [pair for pair in subscriber_event_pairs if len(pair[1]) > 0] 45 | 46 | for subscriber_event_pair in subscriber_event_pairs: 47 | subscriber = subscriber_event_pair[0] 48 | sub_events = subscriber_event_pair[1] 49 | message = format_message_for_events(sub_events, message_type) 50 | send_sms(message, subscriber['phone_number']) 51 | 52 | return 'OK' 53 | except Exception as e: 54 | logger.error(e) 55 | return "Error sending SMS. See logs for info" 56 | -------------------------------------------------------------------------------- /backend/sms.py: -------------------------------------------------------------------------------- 1 | from typing import List, Sequence 2 | from twilio.rest import Client 3 | from config.env import TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_MESSAGING_SERVICE_SID 4 | import logger 5 | from enums import MessageType 6 | from config.types import Event 7 | 8 | account_sid = TWILIO_ACCOUNT_SID 9 | auth_token = TWILIO_AUTH_TOKEN 10 | messaging_service_sid = TWILIO_MESSAGING_SERVICE_SID 11 | sender = 'Bedpres Bot' 12 | 13 | 14 | def __stringify_event_list(events: Sequence[Event]) -> List[str]: 15 | return [event['title'] for event in events] 16 | 17 | 18 | def __list_events_as_string(events: Sequence[str]) -> str: 19 | return ' - ' + '\n - '.join(events) 20 | 21 | 22 | def __format_registration_start_message_for_events(events: Sequence[Event]) -> str: 23 | formatted_titles = __stringify_event_list(events) 24 | titles_as_bulletin = __list_events_as_string(formatted_titles) 25 | return f"Påmelding åpner om 5 minutter til:\n{titles_as_bulletin}" 26 | 27 | 28 | def __format_unattend_message_for_events(events: Sequence[Event]) -> str: 29 | formatted_titles = __stringify_event_list(events) 30 | titles_as_bulletin = __list_events_as_string(formatted_titles) 31 | return f"Avmeldingsfrist om 1 time til:\n{titles_as_bulletin}" 32 | 33 | 34 | def __format_event_start_message_for_events(events: Sequence[Event]) -> str: 35 | formatted_titles = __stringify_event_list(events) 36 | titles_as_bulletin = __list_events_as_string(formatted_titles) 37 | return f"Om 1 time starter:\n{titles_as_bulletin}" 38 | 39 | 40 | def format_message_for_events(events: Sequence[Event], message_type: MessageType) -> str: 41 | if (message_type == MessageType.REGISTRATION_START): 42 | return __format_registration_start_message_for_events(events) 43 | elif (message_type == MessageType.UNATTEND): 44 | return __format_unattend_message_for_events(events) 45 | elif (message_type == MessageType.EVENT_START): 46 | return __format_event_start_message_for_events(events) 47 | else: 48 | return f"message type {message_type} not supported" 49 | 50 | def send_sms(message: str, phone_number: str) -> None: 51 | client = Client(account_sid, auth_token) 52 | confirmation = client.messages.create( 53 | to=phone_number, 54 | from_=sender, 55 | body=message, 56 | messaging_service_sid=messaging_service_sid, 57 | ) 58 | logger.info( 59 | f"Sent sms to {phone_number} with confirmation code {confirmation.sid}. Message: {message}") 60 | 61 | 62 | def send_multiple_sms(message: str, phone_numbers: List[str]) -> None: 63 | for phone_number in phone_numbers: 64 | send_sms(message, phone_number) 65 | -------------------------------------------------------------------------------- /frontend/app/auth/redirect/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import LoadingSpinner from '../../../components/LoadingSpinner'; 4 | import React, { useEffect, useState } from 'react'; 5 | import { useQuery } from 'react-query'; 6 | import { redirect, useRouter } from 'next/navigation'; 7 | 8 | async function fetchAccessToken(authCode?: string, codeVerifier?: string) { 9 | const res = await fetch('/api/auth/accessToken', { 10 | method: 'POST', 11 | body: JSON.stringify({ 12 | authCode, 13 | codeVerifier, 14 | }), 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | Accept: 'application/json', 18 | }, 19 | }); 20 | 21 | const jwtData = await res.json(); 22 | 23 | if (document && jwtData && jwtData.access_token) { 24 | document.cookie = `token=${jwtData.access_token};path=/`; 25 | } 26 | 27 | return jwtData; 28 | } 29 | 30 | async function fetchUser(authCode?: string, codeVerifier?: string) { 31 | if (!authCode || !codeVerifier) { 32 | throw new Error('no authCode or codeVerifier in localStorage'); 33 | } 34 | 35 | await fetchAccessToken(authCode, codeVerifier); 36 | 37 | const res = await fetch('/api/auth/user', { 38 | method: 'GET', 39 | headers: { 40 | 'Content-Type': 'application/json', 41 | Accept: 'application/json', 42 | }, 43 | }); 44 | 45 | const userInfo = await res.json(); 46 | 47 | if (typeof window !== 'undefined') { 48 | window.localStorage.removeItem('code_verifier'); 49 | window.localStorage.removeItem('code_challenge'); 50 | } 51 | 52 | return userInfo; 53 | } 54 | 55 | export default function RedirectPage() { 56 | const [authCode] = useState( 57 | typeof window !== 'undefined' 58 | ? new URLSearchParams(window.location.search).get('code') || undefined 59 | : undefined 60 | ); 61 | const [codeVerifier] = useState( 62 | typeof window !== 'undefined' 63 | ? window.localStorage.getItem('code_verifier') || undefined 64 | : undefined 65 | ); 66 | 67 | const router = useRouter(); 68 | 69 | const { data: userInfo, error } = useQuery( 70 | 'userInfo', 71 | () => fetchUser(authCode, codeVerifier), 72 | { 73 | retry: false, 74 | } 75 | ); 76 | 77 | useEffect(() => { 78 | if (error) { 79 | alert(`Kunne ikke logge inn.\n\n${error}`); 80 | redirect('/'); 81 | } 82 | }, [error]); 83 | 84 | useEffect(() => { 85 | if (!!userInfo && router) { 86 | // TODO: Redirect to register/dashboard depending on whether the user is new or not 87 | // router.push('/register'); 88 | router.push('/dashboard'); 89 | } 90 | }, [userInfo, router]); 91 | 92 | return ; 93 | } 94 | -------------------------------------------------------------------------------- /frontend/app/register/phoneVerification.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | import { useQuery } from 'react-query'; 5 | import VerificationCodeInput from '../../components/VerificationCodeInput'; 6 | 7 | export default function VerifyPhonePage({ 8 | phoneNumber, 9 | onSuccess, 10 | }: { 11 | phoneNumber: string; 12 | onSuccess: () => void; 13 | }) { 14 | const [code, setCode] = useState(''); 15 | const [error, setError] = useState(null); 16 | const { data } = useQuery('sendPhoneVerification', () => 17 | fetch('/api/sms/otp/generate', { 18 | method: 'POST', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | }, 22 | body: JSON.stringify({ 23 | phoneNumber, 24 | }), 25 | }) 26 | .then((res) => res.json()) 27 | .catch((err) => setError(err?.error || 'Noe gikk galt')) 28 | ); 29 | 30 | useEffect(() => { 31 | if (code.length === 5) { 32 | if (data?.cipher === undefined) return; 33 | submitVerificationCode(code, phoneNumber, data.cipher) 34 | .then(onSuccess) 35 | .catch((err) => setError(err.message)); 36 | } else if (code.length > 0) { 37 | setError(null); 38 | } else { 39 | setCode(code.slice(0, 5)); 40 | } 41 | }, [code, data, onSuccess, setError, phoneNumber, setCode]); 42 | 43 | return ( 44 |
45 |

46 | Bekreft telefonnummer 47 |

48 |

49 | Du skal ha fått en SMS til {phoneNumber}. Vennligst skriv inn koden du 50 | mottok. 51 |

52 | {error &&

{error}

} 53 | 54 |
55 | ); 56 | } 57 | 58 | async function submitVerificationCode( 59 | code: string, 60 | phoneNumber: string, 61 | cipher: string 62 | ) { 63 | const response = await fetch('/api/sms/otp/verify', { 64 | method: 'POST', 65 | headers: { 66 | 'Content-Type': 'application/json', 67 | }, 68 | body: JSON.stringify({ 69 | code, 70 | phoneNumber, 71 | cipher, 72 | }), 73 | }); 74 | 75 | if (response.status === 401) { 76 | throw new Error('Koden du skrev inn er feil. Vennligst prøv igjen.'); 77 | } 78 | if (response.status === 410) { 79 | throw new Error('Verifiseringskoden har utløpt'); 80 | } 81 | if (!response.ok) { 82 | throw new Error('Koden du skrev inn er feil. Vennligst prøv igjen.'); 83 | } 84 | return response.json(); 85 | } 86 | -------------------------------------------------------------------------------- /frontend/app/dashboard/upcomingRegistrationsEventsList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import formatDate, { getEventTime } from '../../utils/date/formatDate'; 5 | import { IEvent } from '../../database/models/Event'; 6 | import capitalize from '../../utils/capitalize'; 7 | import eventTypeToIcon from '../../utils/events/eventTypeToIcon'; 8 | import PrimaryButton from '../../components/PrimaryToggle'; 9 | 10 | interface IEventWithNotificationStatus extends IEvent { 11 | sendNotification: boolean; 12 | } 13 | 14 | interface IProps { 15 | events: IEventWithNotificationStatus[]; 16 | } 17 | 18 | export default function EventList({ events }: IProps) { 19 | return ( 20 |
21 | {events.map((event: IEventWithNotificationStatus) => ( 22 |
23 |

24 | {capitalize(formatDate(event['registration_start']))}{' '} 25 | {getEventTime(event['registration_start'])} 26 |

27 |
28 | 29 |
30 |
31 | ))} 32 |
33 | ); 34 | } 35 | 36 | function Event({ event }: { event: IEventWithNotificationStatus }) { 37 | const [sendNotification, setSendNotification] = React.useState( 38 | event.sendNotification 39 | ); 40 | const [loading, setLoading] = React.useState(false); 41 | 42 | const toggleNotification = async () => { 43 | if (loading) return; 44 | const newValue = !sendNotification; 45 | setLoading(true); 46 | setSendNotification(newValue); 47 | await fetch(`/api/notification/${event.id}`, { 48 | method: newValue ? 'DELETE' : 'POST', 49 | }); 50 | setLoading(false); 51 | }; 52 | 53 | return ( 54 |
55 | 61 |
62 |
63 | {eventTypeToIcon(event.event_type, { size: 36 })} 64 |

{event.title}

65 |
66 |

{event.ingress}

67 |
68 |
69 |
70 | 71 | {sendNotification ? 'Slå av varsling' : 'Slå på varsling'} 72 | 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /backend/config/types.py: -------------------------------------------------------------------------------- 1 | from typing import List, Literal, Optional 2 | from bson import objectid 3 | from mypy_extensions import TypedDict 4 | 5 | 6 | class StudentVerv(TypedDict): 7 | id: int 8 | committee: str 9 | position: str 10 | period: str 11 | period_start: str 12 | period_end: str 13 | 14 | class StudentSpecialVerv(TypedDict): 15 | id: int 16 | since_year: int 17 | position: str 18 | 19 | class PublicProfile(TypedDict): 20 | id: int 21 | first_name: str 22 | last_name: str 23 | username: str 24 | 25 | class EventAttendee(TypedDict): 26 | id: int 27 | event: int 28 | full_name: str 29 | is_visible: bool 30 | year_of_study: int 31 | field_of_study: str 32 | 33 | class EventImage(TypedDict): 34 | id: int 35 | name: str 36 | timestamp: str 37 | description: str 38 | thumb: str 39 | original: str 40 | wide: str 41 | lg: str 42 | md: str 43 | sm: str 44 | xs: str 45 | tags: List[str] 46 | photographer: str 47 | preset: str 48 | preset_display: str 49 | 50 | class OWData(TypedDict): 51 | _id: objectid.ObjectId # MongoDB ObjectId 52 | id: int # OW user ID 53 | username: str 54 | nickname: Optional[str] 55 | first_name: str 56 | last_name: str 57 | phone_number: str 58 | online_mail: Optional[str] 59 | address: str 60 | zip_code: str 61 | email: str 62 | website: Optional[str] 63 | github: Optional[str] 64 | linkedin: Optional[str] 65 | ntnu_username: Optional[str] 66 | field_of_study: int 67 | year: int 68 | bio: str 69 | positions: List[StudentVerv] 70 | special_positions: List[StudentSpecialVerv] 71 | image: str 72 | started_date: str 73 | 74 | 75 | class Subscriber(TypedDict): 76 | _id: objectid.ObjectId 77 | phone_number: str 78 | ow: Optional[OWData] 79 | should_receive_ads: bool 80 | ads_received: List[str] 81 | 82 | event_notification_field = Literal[ "registration_start", "unattend_deadline", "start_date" ] 83 | 84 | class Event(TypedDict): 85 | _id: objectid.ObjectId # MongoDB ObjectId 86 | id: int # OW event ID 87 | title: str 88 | slug: str 89 | ingress: str 90 | ingress_short: str 91 | description: str 92 | start_date: str 93 | end_date: str 94 | location: str 95 | event_type: int 96 | event_type_display: str 97 | organizer: int 98 | author: Optional[PublicProfile] 99 | images: List[EventImage] 100 | companies: List[str] 101 | is_attendance_event: bool 102 | max_capacity: int 103 | number_of_seats_taken: int 104 | registration_start: event_notification_field 105 | registration_end: event_notification_field 106 | unattend_deadline: event_notification_field 107 | number_on_waitlist: int 108 | rule_bundles: List[int] 109 | 110 | class EventWithAttendees(Event): 111 | attendees: List[EventAttendee] 112 | 113 | class Ad(TypedDict): 114 | _id: objectid.ObjectId 115 | key: str 116 | text: str 117 | is_active: bool 118 | priority_order: int -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jovial Online Bedpres Bot (JOBB) 2 | 3 | ## For English, press 9 4 | 5 | This is a messaging bot to notify users about student events at my university. As all of the users and the application are Norwegian, the rest of this README is written in Norwegian. It is still possible to contribute in English, as all the issues, commit messages and pull requests are done in English, but I assume most of the contributors would be fellow students. 6 | 7 | ## Hva er dette? 🤔 8 | 9 | JOBB, aka Bedpres Bot, er en meldingsbot som sender en SMS før påmelding til bedpres for linjeforeningen min, Online, på NTNU. Det var egentlig bare noe jeg lagde for meg selv og mine kompiser, men det er åpent for alle! Bare send en SMS til +1 573 538 2475 med kodeord ONLINE, så får du en SMS 5 minutter før påmelding åpner. Foreløpig er det ingen filtrering, så du får melding om alle arrangementer fra OW. Jeg jobber også med en web-applikasjon (se frontend-mappa) som skal gjøre det lettere å registrere seg. 10 | 11 | ## Hvilke teknologier er brukt? 💻 12 | 13 | Først hadde jeg tenkt å scrape OW, så jeg startet med Python fordi det har jeg brukt tidligere til scraping. Men jeg fant ut at OW har et åpent API, så da bare sender jeg bare web requests. Men Python funker uansett bra, så jeg fortsatte med det! For å sende SMS bruker jeg Twilio. Backend er delt opp i to: et Flask API som innkommende meldinger via Twilio sendes til, og diverse serverless funksjoner som kjører på Google Cloud. Se README i _backend/_ for mer detaljer. Jeg jobber også med en frontend som er laget i React med Next og TypeScript. 14 | 15 | ## Hvordan kan jeg bidra? 🙋‍♂️ 16 | 17 | - _Jeg vil bidra med kode!_ - Sjekk ut issues på GitHub. Enkle issues skal være markert med `good first issue`. Hvis du har noen spørsmål, så bare spør! Du kan også lage nye issues hvis du har en idé til en feature eller noe som kan forbedres. 18 | - _Jeg har ikke tid/lyst til å kode, men kan jeg bidra på andre måter??_ - Så hyggelig at du spør! Det å sende ut SMS er ikke veldig dyrt, men det er ikke gratis heller, så hvis du liker Bedpres Bot (_shameless self promotion incoming..._) og vil støtte tjenesten kan du gjerne vippse meg på 936 71 222. Koster meg totalt cirka 500kr i måneden, avhengig av hvor mange brukere jeg får og hvor mange bedpres Online organiserer 👀 19 | 20 | ## Hva er planen framover? 📝 21 | 22 | - [x] ~~Lage en enkel backend som sender SMS til subscribers før påmeldingsfrist.~~ 23 | - [x] ~~Legge til funksjonalitet i backend for å sende ut påminnelse før avmeldingsfrist. For å gjøre dette må OW-data integreres med nåværende subscribers i databasen, og brukere meldt på arrangementet må sammenlignes med subscribers for å finne ut hvem som skal få SMS.~~ 24 | - [x] ~~Ad support 💰. Målet med Bedpres Bot er på ingen måte å tjene penger, men det hadde vært fint å ikke tape mye penger heller. Jeg tenkte derfor vi kan sende ut SMS regelmessig (tenker maks én gang i måneden) med en referral til en eller annen tjeneste jeg bruker. For denne løsningen er det viktig å ikke spamme folk, så jeg har lyst til å ha oversikt over hvilke ads brukere har mottatt, sånn at de ikke får samme ad flere ganger. Brukere som har vippset synes jeg bør få en ad-fri versjon av tjenesten.~~ 25 | - [ ] Lage en frontend med mulighet til å vise kommende bedpres og slå av og på SMS-varsel. Må ha innlogging med Online-bruker. 26 | -------------------------------------------------------------------------------- /frontend/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import AllEvents from './allEvents'; 3 | import MyEvents from './myEvents'; 4 | import FilterHeader from './eventFilterHeader'; 5 | import UpcomingRegistrations from './upcomingRegistrations'; 6 | 7 | export default async function DashboardPage() { 8 | return ( 9 |
10 | 11 |
Mine arrangementer
12 | }> 13 | {/* @ts-expect-error Server Component */} 14 | 15 | 16 |
17 | 18 |
Kommende påmeldinger
19 | }> 20 | {/* @ts-expect-error Server Component */} 21 | 22 | 23 |
24 | 25 |
26 |
Alle arrangementer
27 | 28 |
29 | }> 30 | {/* @ts-expect-error Server Component */} 31 | 32 | 33 |
34 |
35 | ); 36 | } 37 | 38 | function Card({ children, className }: any) { 39 | return ( 40 |
43 |
{children}
44 |
45 | ); 46 | } 47 | 48 | function Header({ children }: any) { 49 | return ( 50 |

{children}

51 | ); 52 | } 53 | 54 | function SkeletonEventList({ numEvents }: { numEvents: number }) { 55 | return ( 56 |
57 | {Array.from({ length: numEvents }).map((_, i) => ( 58 | 59 | ))} 60 |
61 | ); 62 | } 63 | 64 | function SkeletonEvent() { 65 | return ( 66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /frontend/database/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Mongoose } from 'mongoose'; 2 | import Subscriber, { ISubscriber } from './models/Subscriber'; 3 | import Event, { IEvent } from './models/Event'; 4 | import Exclusion, { IExclusion } from './models/Exclusion'; 5 | 6 | class Database { 7 | private connectionURI: string; 8 | private connection: Mongoose | undefined; 9 | private connectingAttempt: Promise | undefined; 10 | 11 | constructor() { 12 | const { MONGODB_URI } = process.env; 13 | if (!MONGODB_URI) { 14 | throw new Error('MONGODB_URI is not set'); 15 | } 16 | this.connectionURI = MONGODB_URI; 17 | } 18 | 19 | private async connect(): Promise { 20 | if (this.connection) return this.connection; 21 | if (this.connectingAttempt) { 22 | // we're already connecting, so wait for that to finish instea of starting a new connection 23 | return this.connectingAttempt; 24 | } 25 | 26 | this.connectingAttempt = mongoose.connect(this.connectionURI); 27 | const connection = await this.connectingAttempt; 28 | this.connection = connection; 29 | this.connectingAttempt = undefined; 30 | return connection; 31 | } 32 | 33 | public async fetchUser(id: number): Promise { 34 | await this.connect(); 35 | 36 | const user = (await Subscriber.findOne({ 37 | 'ow.id': id, 38 | }) 39 | .select('-id') 40 | .lean()) as ISubscriber | null; 41 | 42 | if (user === null || typeof user.ow === 'string') return null; 43 | 44 | return user; 45 | } 46 | 47 | public async fetchEvents(): Promise { 48 | await this.connect(); 49 | 50 | const currentTime = new Date(); 51 | 52 | const events = (await Event.find({ 53 | start_date: { $gte: currentTime.toISOString() }, 54 | }) 55 | .sort({ 56 | start_date: 1, 57 | }) 58 | .select('-_id') 59 | .lean()) as IEvent[]; 60 | 61 | return events; 62 | } 63 | 64 | public async fetchUpcomingRegistrations(): Promise { 65 | await this.connect(); 66 | 67 | const currentTime = new Date(); 68 | 69 | const events = (await Event.find({ 70 | registration_start: { $gte: currentTime.toISOString() }, 71 | }) 72 | .sort({ 73 | registration_start: 1, 74 | }) 75 | .select('-_id') 76 | .lean()) as IEvent[]; 77 | 78 | return events; 79 | } 80 | 81 | public async fetchExclusions(userId: number): Promise { 82 | await this.connect(); 83 | 84 | const exclusions = (await Exclusion.find({ 85 | subscriber_id: userId, 86 | }) 87 | .select('-_id') 88 | .lean()) as IExclusion[]; 89 | 90 | return exclusions; 91 | } 92 | 93 | public async insertExclusion(exclusion: IExclusion) { 94 | await this.connect(); 95 | 96 | // await Exclusion.create(exclusion); 97 | const exclusionDoc = new Exclusion(exclusion); 98 | await exclusionDoc.save(); 99 | } 100 | 101 | public async removeExclusion(exclusion: IExclusion) { 102 | await this.connect(); 103 | 104 | await Exclusion.deleteMany(exclusion); 105 | } 106 | } 107 | 108 | const database = new Database(); 109 | 110 | export default database; 111 | -------------------------------------------------------------------------------- /frontend/public/loading-spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /backend/api.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request 2 | from twilio.twiml.messaging_response import MessagingResponse 3 | from config.types import Subscriber 4 | from db import Database 5 | import logger 6 | 7 | api = Flask(__name__) 8 | api.debug = False 9 | 10 | default_subscriber = { 11 | "ads_received": [], 12 | "should_receive_ads": True, 13 | } 14 | 15 | database = Database() 16 | database.connect() 17 | 18 | @api.route('/jobb/ping', methods=['GET']) 19 | def ping() -> str: 20 | logger.info("Ping endpoint hit") 21 | return "pong" 22 | 23 | @api.route("/jobb/sms", methods=["POST"]) 24 | def sms() -> str: 25 | logger.info("SMS endpoint hit") 26 | phone_number = request.form["From"] 27 | message = request.form["Body"] 28 | response_message = evaluate_user_message(phone_number, message) 29 | response = MessagingResponse() 30 | response.message(response_message) 31 | return str(response) 32 | 33 | def handle_subscribe(phone_number: str) -> str: 34 | logger.info(f"New subscriber: {phone_number}") 35 | new_subscriber = map_phone_number_to_subscriber(phone_number) 36 | successfully_added = add_subscriber(new_subscriber) 37 | if successfully_added: 38 | return "Du er nå abonnert på bedpres-oppdateringer! Send OFFLINE hvis du ikke lenger vil ha oppdateringer." 39 | else: 40 | return "Du er allerede abonnert på bedpres-oppdateringer. Send OFFLINE hvis du ikke lenger vil ha oppdateringer." 41 | 42 | def handle_unsubscribe(phone_number: str) -> str: 43 | logger.info(f"Removed subscriber: {phone_number}") 44 | successfully_removed = remove_subscriber(phone_number) 45 | if successfully_removed: 46 | return "Du er nå avmeldt bedpres-oppdateringer." 47 | else: 48 | return "Du er ikke abonnert. Skriv ONLINE for å abonnere på bedpres-oppdateringer." 49 | 50 | def handle_unknown_command(phone_number: str, user_message: str) -> str: 51 | logger.debug(f"Unknown message from {phone_number}: {user_message}") 52 | return "Ukjent kommando. Send ONLINE for å abonnere, og OFFLINE for å avslutte abonnementet." 53 | 54 | def evaluate_user_message(phone_number: str, user_message: str) -> str: 55 | formatted_message = user_message.lower().strip() 56 | if formatted_message == "online": 57 | return handle_subscribe(phone_number) 58 | elif formatted_message in ["avslutt", "offline"]: 59 | return handle_unsubscribe(phone_number) 60 | else: 61 | return handle_unknown_command(phone_number, user_message) 62 | 63 | 64 | def map_phone_number_to_subscriber(phone_number: str) -> Subscriber: 65 | if not database.is_connected: 66 | database.connect() 67 | ow_data = database.get_ow_data_for_phone_number(phone_number) 68 | return { 69 | **default_subscriber, # type: ignore 70 | 'phone_number': phone_number, 71 | 'ow': ow_data, 72 | } 73 | 74 | def add_subscriber(subscriber: Subscriber) -> bool: 75 | if not database.is_connected: 76 | database.connect() 77 | phone_number = subscriber['phone_number'] 78 | if database.subscriber_exists(phone_number): 79 | return False 80 | if database.subscriber_exists(phone_number, True): 81 | return database.re_activate_subscriber(phone_number) 82 | return database.add_subscriber(subscriber) 83 | 84 | def remove_subscriber(phone_number: str) -> bool: 85 | if not database.is_connected: 86 | database.connect() 87 | did_unsubscribe = database.remove_subscriber(phone_number) 88 | return did_unsubscribe 89 | 90 | if __name__ == "__main__": 91 | api.run() 92 | -------------------------------------------------------------------------------- /backend/online.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from requests import get # type: ignore 3 | from config.env import OW_COOKIE 4 | from utils import date_is_in_the_future, get_current_date 5 | import logger 6 | from config.types import Event, EventAttendee 7 | 8 | def __get_auth_headers(): 9 | return {'Cookie': OW_COOKIE} 10 | 11 | def get_event_list() -> List[Event]: 12 | date = get_current_date().date() 13 | url = f'https://old.online.ntnu.no/api/v1/event/events/?format=json&event_end__gte={date}&page_size=50' 14 | try: 15 | response = get(url) 16 | if not response.ok: 17 | logger.error(f'Failed to fetch events from OW. Received status code {response.status_code}') 18 | return [] 19 | data = response.json() 20 | return data['results'] 21 | except: 22 | logger.error('Failed to fetch events from OW') 23 | return [] 24 | 25 | def event_is_in_the_future(event: Event) -> bool: 26 | try: 27 | event_id = event['id'] 28 | start_date = event['start_date'] 29 | response = get(f'https://old.online.ntnu.no/api/v1/event/attendance-events/{event_id}/?format=json', timeout=30) 30 | if not response.ok: 31 | logger.error(f'Failed to fetch attendance event {event["id"]} from OW. Received status code {response.status_code}') 32 | return False 33 | data = response.json() 34 | registration_start = data['registration_start'] 35 | if registration_start is not None and date_is_in_the_future(registration_start): 36 | return True 37 | if start_date is not None and date_is_in_the_future(start_date): 38 | return True 39 | return False 40 | except: 41 | logger.error(f'Failed to fetch event {event_id} from OW') 42 | return False 43 | 44 | def add_attendance_event_data_to_event(event: Event) -> Event: 45 | try: 46 | response = get(f'https://old.online.ntnu.no/api/v1/event/attendance-events/{event["id"]}/?format=json', timeout=30) 47 | if not response.ok: 48 | logger.error(f'Failed to fetch attendance event {event["id"]} from OW. Received status code {response.status_code}') 49 | return event 50 | data = response.json() 51 | registration_start = data['registration_start'] 52 | registration_end = data['registration_end'] 53 | unattend_deadline = data['unattend_deadline'] 54 | number_on_waitlist = data['number_on_waitlist'] 55 | rule_bundles = data['rule_bundles'] 56 | event['registration_start'] = registration_start 57 | event['registration_end'] = registration_end 58 | event['unattend_deadline'] = unattend_deadline 59 | event['number_on_waitlist'] = number_on_waitlist 60 | event['rule_bundles'] = rule_bundles 61 | return event 62 | except: 63 | logger.error(f'Failed to fetch event {event["id"]} from OW') 64 | return event 65 | 66 | def get_attendees_for_event(event: Event) -> List[EventAttendee]: 67 | url = f'https://old.online.ntnu.no/api/v1/event/attendance-events/{event["id"]}/public-attendees/?format=json' 68 | try: 69 | response = get(url, timeout=30, headers=__get_auth_headers()) 70 | if not response.ok: 71 | logger.error(f'Failed to fetch attendance event {event["id"]} from OW. Received status code {response.status_code}') 72 | return [] 73 | users = response.json() 74 | return users 75 | except: 76 | logger.error(f'Failed to fetch event {event["id"]} from OW') 77 | return [] 78 | -------------------------------------------------------------------------------- /frontend/components/EventTypeToggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | import React, { useState } from 'react'; 5 | import eventTypeToIcon from '../utils/events/eventTypeToIcon'; 6 | import { EventType } from '../utils/events/types'; 7 | 8 | export default function EventToggle({ eventType }: { eventType: EventType }) { 9 | const [isToggled, setIsToggled] = useState(true); 10 | 11 | const toggle = () => { 12 | setIsToggled(!isToggled); 13 | }; 14 | 15 | return ( 16 |
17 | 18 | {isToggled && } 19 | 20 | 21 |
22 | ); 23 | } 24 | 25 | function Content({ eventType }: { eventType: EventType }) { 26 | // TODO: Consider moving this to a json file or possibly Vercel's edge config. 27 | const eventTypeToTitle = { 28 | bedpres: 'Bedriftspresentasjoner', 29 | kurs: 'Kurs', 30 | sosialt: 'Sosiale arrangementer', 31 | }; 32 | const eventTypeToDescription = { 33 | bedpres: 34 | 'En bedrift kommer og forteller om arbeidsplassen og presenterer sommerjobber. Deretter drar vi på restaurant og får gratis mat og drikke.', 35 | kurs: 'Litt som bedpres, men med et faglig kurs før mat og drikke. Her kan du lære noe arbeidsrelevant som man kanskje ikke lærer på studiet.', 36 | sosialt: 37 | 'Alle andre sosiale arrangementer. Dette kan være alt fra store events som surfing i Portugal eller ski i Åre, til mindre events som Go-kart eller casino-kveld.', 38 | }; 39 | return ( 40 | <> 41 |

42 | {eventTypeToTitle[eventType]} 43 |

44 |
45 | {eventTypeToIcon(eventType)} 46 |
47 |

48 | {eventTypeToDescription[eventType]} 49 |

50 | 51 | ); 52 | } 53 | 54 | function CheckMark({ eventType }: { eventType: EventType }) { 55 | const eventTypeToStyle = { 56 | bedpres: 'border-t-event-bedpres border-l-event-bedpres', 57 | kurs: 'border-t-event-kurs border-l-event-kurs', 58 | sosialt: 'border-t-event-sosialt border-l-event-sosialt', 59 | }; 60 | return ( 61 |
62 |
65 | checkmark 72 |
73 | ); 74 | } 75 | 76 | function Border({ 77 | children, 78 | isToggled, 79 | toggle, 80 | eventType, 81 | }: { 82 | children: React.ReactNode; 83 | isToggled: boolean; 84 | toggle: () => void; 85 | eventType: EventType; 86 | }) { 87 | const eventTypeToBorderStyle = { 88 | bedpres: 'border-event-bedpres', 89 | kurs: 'border-event-kurs', 90 | sosialt: 'border-event-sosialt', 91 | }; 92 | let styling = ''; 93 | if (isToggled) 94 | styling = `${eventTypeToBorderStyle[eventType]} h-full bg-background-accent cursor-pointer border-4 border-opacity-100`; 95 | else 96 | styling = 97 | 'h-full bg-background-accent cursor-pointer border-4 border-background border-opacity-0'; 98 | return ( 99 |
100 | {children} 101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /frontend/app/dashboard/allEventsList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { IEvent } from '../../database/models/Event'; 4 | import { 5 | EventTypeFilter, 6 | useEventFilterContext, 7 | } from '../../providers/EventFilterContext'; 8 | import formatDate from '../../utils/date/formatDate'; 9 | import { parseEventType } from '../../utils/events/parseEventType'; 10 | import capitalize from '../../utils/capitalize'; 11 | import { getEventTime } from '../../utils/date/formatDate'; 12 | import eventTypeToIcon from '../../utils/events/eventTypeToIcon'; 13 | 14 | export default function EventList({ events }: { events: IEvent[] }) { 15 | const { selectedEventTypeFilter } = useEventFilterContext(); 16 | 17 | const groupedEvents = events.reduce(groupEvents(selectedEventTypeFilter), {}); 18 | 19 | return ( 20 |
21 | {Object.entries(groupedEvents).map(([date, events]) => ( 22 |
23 |

24 | {capitalize(formatDate(date))} 25 |

26 |
    27 | {events.map((event: IEvent) => ( 28 |
    32 | 33 |
    34 | ))} 35 |
36 |
37 | ))} 38 |
39 | ); 40 | } 41 | 42 | interface IEventGroup { 43 | [key: string]: IEvent[]; 44 | } 45 | 46 | function groupEvents(eventTypeFilter: EventTypeFilter) { 47 | return function (acc: IEventGroup, curr: IEvent) { 48 | if ( 49 | eventTypeFilter !== 'alle' && 50 | parseEventType(curr.event_type) !== eventTypeFilter 51 | ) 52 | return acc; 53 | const dayOfYear = curr['start_date'].split('T')[0]; 54 | if (acc[dayOfYear]) { 55 | acc[dayOfYear].push(curr); 56 | } else { 57 | acc[dayOfYear] = [curr]; 58 | } 59 | return acc; 60 | }; 61 | } 62 | 63 | function Event({ event }: { event: IEvent }) { 64 | return ( 65 | 85 | ); 86 | } 87 | 88 | function AvailableSpaces({ event }: { event: IEvent }) { 89 | if (new Date(event.registration_end).getTime() < new Date().getTime()) { 90 | return

Påmelding er stengt

; 91 | } 92 | const remainingSpaces = event.max_capacity - event.number_of_seats_taken; 93 | if (remainingSpaces === 0) { 94 | if (typeof event.number_on_waitlist === 'number') { 95 | return ( 96 |

97 | {event.number_on_waitlist} på venteliste 98 |

99 | ); 100 | } 101 | return

Fullt

; 102 | } 103 | return ( 104 |

{remainingSpaces} ledige plasser

105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /frontend/components/VerificationCodeInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useRef } from 'react'; 4 | import { motion, useAnimationControls } from 'framer-motion'; 5 | 6 | export default function VerificationCodeInput({ 7 | error, 8 | code, 9 | onChangeCode, 10 | }: { 11 | error: string | null; 12 | code: string; 13 | onChangeCode: React.Dispatch>; 14 | }) { 15 | const errorAnimationControls = useAnimationControls(); 16 | 17 | const inputRef = useRef<(HTMLInputElement | null)[]>([]); 18 | 19 | useEffect(() => { 20 | if (error) { 21 | inputRef.current[0]?.focus(); 22 | errorAnimationControls.start({ 23 | x: [ 24 | '0px', 25 | '10px', 26 | '0px', 27 | '-10px', 28 | '0px', 29 | '10px', 30 | '0px', 31 | '-10px', 32 | '0px', 33 | ], 34 | }); 35 | } 36 | }, [error, errorAnimationControls]); 37 | 38 | useEffect(() => { 39 | inputRef.current[code.length]?.focus(); 40 | if (code.length >= 5) { 41 | inputRef.current[4]?.blur(); 42 | } 43 | }, [inputRef, code]); 44 | 45 | const handleInput = (e: React.ChangeEvent) => { 46 | const { value } = e.target; 47 | if (value.length > 1) { 48 | if (!error) { 49 | const filteredValue = value.replace(/[^0-9]/g, ''); 50 | const newCode = code + filteredValue; 51 | onChangeCode(newCode); 52 | return; 53 | } 54 | onChangeCode(value.charAt(value.length - 1)); 55 | inputRef.current.forEach((input) => { 56 | if (input && input) input.value = ''; 57 | }); 58 | return; 59 | } 60 | onChangeCode((prev) => prev + value); 61 | }; 62 | 63 | const onKeyDown = (e: React.KeyboardEvent) => { 64 | if (e.key === 'Backspace') { 65 | onChangeCode((prev) => prev.slice(0, -1)); 66 | } 67 | }; 68 | 69 | return ( 70 | <> 71 | 77 | 85 | 93 | 101 | 109 | 117 | 118 | {error && ( 119 |

120 | {error ?? 'Koden du skrev inn er feil. Vennligst prøv igjen.'} 121 |

122 | )} 123 | 124 | ); 125 | } 126 | 127 | function SingleNumberInput({ 128 | index, 129 | code, 130 | onChange, 131 | onKeyDown, 132 | inputRef, 133 | error, 134 | }: any) { 135 | return ( 136 |
137 | (inputRef.current[index] = el)} 143 | onChange={onChange} 144 | onKeyDown={onKeyDown} 145 | value={code[index]} 146 | inputMode='numeric' 147 | pattern='[0-9]*' 148 | autoComplete='one-time-code' 149 | disabled={code.length !== index && (index !== 0 || !error)} 150 | style={{ caretColor: 'transparent' }} 151 | /> 152 |
153 | ); 154 | } 155 | -------------------------------------------------------------------------------- /frontend/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { parseJWTPayload, verifyOWJwtSignature } from '../utils/crypto'; 2 | 3 | interface IJwtResponse { 4 | access_token: string; 5 | expires_in: number; 6 | id_token: string; 7 | refresh_token: string; 8 | token_type: string; 9 | } 10 | 11 | interface IUserInfo { 12 | sub: number; 13 | name: string; 14 | family_name: string; 15 | given_name: string; 16 | nickname: string; 17 | preferred_username: string; 18 | email: string; 19 | email_verified: boolean; 20 | picture: string; 21 | } 22 | 23 | interface IProfile { 24 | id: number; 25 | username: string; 26 | nickname: string; 27 | first_name: string; 28 | last_name: string; 29 | phone_number: string; 30 | online_mail: string; 31 | address: string; 32 | zip_code: string; 33 | email: string; 34 | website: string; 35 | github: string; 36 | linkedin: string; 37 | ntnu_username: string; 38 | field_of_study: number; 39 | year: number; 40 | bio: string; 41 | positions: string[]; 42 | special_positions: string[]; 43 | image: string; 44 | started_date: string; 45 | } 46 | 47 | class Auth { 48 | private owTokenURL: string; 49 | private owUserInfoURL: string; 50 | private owProfileURL: string; 51 | private clientID: string; 52 | private redirectURI: string; 53 | private jwtData: IJwtResponse | undefined; 54 | private userInfo: IUserInfo | undefined; 55 | private profile: IProfile | undefined; 56 | 57 | constructor() { 58 | const { 59 | OW_TOKEN_URL, 60 | CLIENT_ID, 61 | REDIRECT_URI, 62 | OW_USERINFO_URL, 63 | OW_PROFILE_URL, 64 | } = process.env; 65 | if ( 66 | !OW_TOKEN_URL || 67 | !CLIENT_ID || 68 | !REDIRECT_URI || 69 | !OW_USERINFO_URL || 70 | !OW_PROFILE_URL 71 | ) 72 | throw new Error('Missing auth environment variables'); 73 | this.owTokenURL = OW_TOKEN_URL; 74 | this.owUserInfoURL = OW_USERINFO_URL; 75 | this.clientID = CLIENT_ID; 76 | this.redirectURI = REDIRECT_URI; 77 | this.owProfileURL = OW_PROFILE_URL; 78 | } 79 | 80 | public async fetchAccessToken( 81 | authCode: string, 82 | codeVerifier: string 83 | ): Promise { 84 | const url = new URL(this.owTokenURL); 85 | 86 | const body = new FormData(); 87 | 88 | body.append('client_id', this.clientID); 89 | body.append('redirect_uri', this.redirectURI); 90 | body.append('grant_type', 'authorization_code'); 91 | body.append('code_verifier', codeVerifier); 92 | body.append('code', authCode); 93 | 94 | const response = await fetch(url.toString(), { 95 | method: 'POST', 96 | body, 97 | }); 98 | if (!response.ok) throw new Error('Failed to fetch access token'); 99 | 100 | const data = await response.json(); 101 | 102 | this.jwtData = data; 103 | 104 | console.log(data); 105 | return data; 106 | } 107 | 108 | public async fetchUser() { 109 | if (!this.jwtData) throw new Error('No JWT data'); 110 | 111 | const url = new URL(this.owUserInfoURL); 112 | 113 | const response = await fetch(url.toString(), { 114 | headers: { 115 | Authorization: `Bearer ${this.jwtData.access_token}`, 116 | }, 117 | }); 118 | 119 | if (!response.ok) throw new Error('Failed to fetch user'); 120 | 121 | const data = await response.json(); 122 | 123 | this.userInfo = data; 124 | 125 | return data; 126 | } 127 | 128 | public async fetchFullProfile(token?: string) { 129 | if (!this.jwtData && !token) 130 | throw new Error('No JWT data and no token provided'); 131 | 132 | const url = new URL(this.owProfileURL); 133 | 134 | const response = await fetch(url.toString(), { 135 | headers: { 136 | Authorization: `Bearer ${this.jwtData?.access_token || token}`, 137 | }, 138 | }); 139 | 140 | if (!response.ok) throw new Error('Failed to fetch user'); 141 | 142 | const data = await response.json(); 143 | console.log(data); 144 | 145 | this.profile = data; 146 | 147 | return data; 148 | } 149 | } 150 | 151 | export async function verifyJwt( 152 | token: string, 153 | cryptoModule: Crypto, 154 | expirationTime: number 155 | ): Promise { 156 | // 1. Verify signature 157 | const isValidSignature = await verifyOWJwtSignature(token, cryptoModule); 158 | if (!isValidSignature) return false; 159 | 160 | // 2. Verify audience 161 | const jwtPayload = parseJWTPayload(token); 162 | const isValidAudience = jwtPayload.aud === process.env.CLIENT_ID; 163 | if (!isValidAudience) return false; 164 | 165 | // 3. Verify issued at 166 | const currentTimestamp = Date.now() / 1000; 167 | const isValidIssuedAt = 168 | jwtPayload.iat > Math.floor(currentTimestamp - expirationTime) && 169 | jwtPayload.iat < Math.floor(currentTimestamp); 170 | if (!isValidIssuedAt) return false; 171 | 172 | return true; 173 | } 174 | 175 | const auth = new Auth(); 176 | 177 | export default auth; 178 | -------------------------------------------------------------------------------- /frontend/utils/crypto/index.ts: -------------------------------------------------------------------------------- 1 | import { IEncrypted, IJwk, IJWTPayload } from './types'; 2 | 3 | const ivLength = 12; 4 | const secretKeyString = process.env.ENCRYPTION_KEY; 5 | if (!secretKeyString) { 6 | throw new Error('Encryption key is not set'); 7 | } 8 | 9 | class JWK { 10 | private jwks: IJwk[]; 11 | private owJwkUrl: string; 12 | 13 | constructor() { 14 | this.jwks = []; 15 | const { OW_JWK_URL } = process.env; 16 | if (!OW_JWK_URL) throw new Error('Missing OW_JWK_URL environment variable'); 17 | this.owJwkUrl = OW_JWK_URL; 18 | } 19 | 20 | private async fetchOwJwks(): Promise { 21 | return fetch(this.owJwkUrl, { 22 | cache: 'force-cache', 23 | }) 24 | .then((res) => res.json()) 25 | .then((res) => res.keys); 26 | } 27 | 28 | public async getOwJwks(): Promise { 29 | if (this.jwks.length === 0) { 30 | this.jwks = await this.fetchOwJwks(); 31 | } 32 | return this.jwks; 33 | } 34 | 35 | public async refreshJwk(): Promise { 36 | this.jwks = await this.fetchOwJwks(); 37 | } 38 | } 39 | 40 | const jwk = new JWK(); 41 | 42 | function generateEncryptionKey(cryptoModule: Crypto) { 43 | const encodedSecret = encode(secretKeyString!); 44 | return cryptoModule.subtle.importKey( 45 | 'raw', 46 | encodedSecret, 47 | { name: 'AES-GCM', length: 256 }, 48 | true, 49 | ['encrypt', 'decrypt'] 50 | ); 51 | } 52 | 53 | async function generateJwtVerificationKey( 54 | cryptoModule: Crypto, 55 | kid: string 56 | ): Promise { 57 | const keys = await jwk.getOwJwks(); 58 | const matchingKey = keys.find((key) => key.kid === kid); 59 | if (!matchingKey) { 60 | return Promise.reject(new Error('No matching key found')); 61 | } 62 | return cryptoModule.subtle.importKey( 63 | 'jwk', 64 | matchingKey, 65 | { 66 | name: 'RSASSA-PKCS1-v1_5', 67 | hash: { name: 'SHA-256' }, 68 | }, 69 | false, 70 | ['verify'] 71 | ); 72 | } 73 | 74 | function encode(data: string): Uint8Array { 75 | const encoder = new TextEncoder(); 76 | return encoder.encode(data); 77 | } 78 | 79 | function decode(data: ArrayBuffer): string { 80 | const decoder = new TextDecoder(); 81 | return decoder.decode(data); 82 | } 83 | 84 | function generateIv(cryptoModule: Crypto) { 85 | return cryptoModule.getRandomValues(new Uint8Array(ivLength)); 86 | } 87 | 88 | function pack(buffer: ArrayBuffer | Uint8Array): string { 89 | return Buffer.from(buffer).toString('base64'); 90 | } 91 | 92 | function unpack(packed: string): ArrayBuffer { 93 | return Buffer.from(packed, 'base64'); 94 | } 95 | 96 | // For middleware which runs on Next's edge runtime environment as opposed to Node, we need to use atob and btoa because 97 | // the Buffer class is not available. 98 | function edgeRuntimeCompatiblePack(buffer: ArrayBuffer | Uint8Array): string { 99 | return btoa( 100 | new Uint8Array(buffer).reduce( 101 | (data, byte) => data + String.fromCharCode(byte), 102 | '' 103 | ) 104 | ); 105 | } 106 | 107 | function edgeRuntimeCompatibleUnpack(string: string): ArrayBuffer { 108 | var binaryString = atob(string); 109 | var len = binaryString.length; 110 | var bytes = new Uint8Array(len); 111 | for (var i = 0; i < len; i++) { 112 | bytes[i] = binaryString.charCodeAt(i); 113 | } 114 | return bytes.buffer; 115 | } 116 | 117 | function stringifyEncrypted( 118 | encrypted: IEncrypted, 119 | isEdgeRuntime: boolean 120 | ): string { 121 | const packedIv = isEdgeRuntime 122 | ? edgeRuntimeCompatiblePack(encrypted.iv) 123 | : pack(encrypted.iv); 124 | const packedCipher = isEdgeRuntime 125 | ? edgeRuntimeCompatiblePack(encrypted.cipher) 126 | : pack(encrypted.cipher); 127 | const packedMessage = `${packedIv}${packedCipher}`; 128 | return packedMessage; 129 | } 130 | 131 | function parseEncrypted( 132 | packedMessage: string, 133 | isEdgeRuntime: boolean 134 | ): IEncrypted { 135 | const unpackedMessage = isEdgeRuntime 136 | ? edgeRuntimeCompatibleUnpack(packedMessage) 137 | : unpack(packedMessage); 138 | const ivArrayBuffer = unpackedMessage.slice(0, ivLength); 139 | const iv = new Uint8Array(ivArrayBuffer); 140 | const cipher = unpackedMessage.slice(ivLength); 141 | return { 142 | iv, 143 | cipher, 144 | }; 145 | } 146 | 147 | function encodeJwtData(jwt: string): Uint8Array { 148 | const encoder = new TextEncoder(); 149 | const data = jwt.split('.').slice(0, 2).join('.'); 150 | return encoder.encode(data); 151 | } 152 | 153 | function unpackJwtSignature(jwt: string, isEdgeRuntime: boolean): ArrayBuffer { 154 | const rawSignature = jwt.split('.')[2]; 155 | if (isEdgeRuntime) return edgeRuntimeCompatibleUnpack(rawSignature); 156 | return unpack(rawSignature); 157 | } 158 | 159 | export async function encrypt( 160 | data: string, 161 | cryptoModule: Crypto, 162 | isEdgeRuntime?: boolean 163 | ): Promise { 164 | const encoded = encode(data); 165 | const iv = generateIv(cryptoModule); 166 | const key = await generateEncryptionKey(cryptoModule); 167 | const cipher = await cryptoModule.subtle.encrypt( 168 | { 169 | name: 'AES-GCM', 170 | iv: iv, 171 | }, 172 | key, 173 | encoded 174 | ); 175 | return stringifyEncrypted({ cipher, iv }, !!isEdgeRuntime); 176 | } 177 | 178 | export async function decrypt( 179 | encryptedString: string, 180 | cryptoModule: Crypto, 181 | isEdgeRuntime?: boolean 182 | ): Promise { 183 | const encryptedMessage = parseEncrypted(encryptedString, !!isEdgeRuntime); 184 | const { cipher, iv } = encryptedMessage; 185 | const key = await generateEncryptionKey(cryptoModule); 186 | const encoded = await cryptoModule.subtle.decrypt( 187 | { 188 | name: 'AES-GCM', 189 | iv: iv, 190 | }, 191 | key, 192 | cipher 193 | ); 194 | return decode(encoded); 195 | } 196 | 197 | export function parseJWTPayload(jwt: string): IJWTPayload { 198 | const payload = jwt.split('.')[1]; 199 | const base64Payload = payload.replace(/-/g, '+').replace(/_/g, '/'); 200 | var jsonPayload = decodeURIComponent( 201 | atob(base64Payload) 202 | .split('') 203 | .map(function (c) { 204 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 205 | }) 206 | .join('') 207 | ); 208 | return JSON.parse(jsonPayload); 209 | } 210 | 211 | function parseJWTHeaderKid(jwt: string): string { 212 | const header = jwt.split('.')[0]; 213 | const base64Header = header.replace(/-/g, '+').replace(/_/g, '/'); 214 | var jsonHeader = decodeURIComponent( 215 | atob(base64Header) 216 | .split('') 217 | .map(function (c) { 218 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 219 | }) 220 | .join('') 221 | ); 222 | return JSON.parse(jsonHeader).kid; 223 | } 224 | 225 | export async function verifyOWJwtSignature( 226 | jwt: string, 227 | cryptoModule: Crypto, 228 | isEdgeRuntime?: boolean 229 | ): Promise { 230 | const signature = unpackJwtSignature(jwt, !!isEdgeRuntime); 231 | const data = encodeJwtData(jwt); 232 | const kid = parseJWTHeaderKid(jwt); 233 | const verify = async () => { 234 | const publicKey = await generateJwtVerificationKey(cryptoModule, kid); 235 | return cryptoModule.subtle.verify( 236 | { name: 'RSASSA-PKCS1-v1_5' }, 237 | publicKey, 238 | signature, 239 | data 240 | ); 241 | }; 242 | return verify() 243 | .then((isValid) => isValid) 244 | .catch(() => { 245 | jwk.refreshJwk(); 246 | return verify() 247 | .then((isValid) => isValid) 248 | .catch(() => false); 249 | }); 250 | } 251 | -------------------------------------------------------------------------------- /backend/db.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional, cast 2 | from pymongo import MongoClient 3 | from datetime import datetime, timedelta 4 | import pytz # type: ignore 5 | from config.env import ENVIRONMENT, MONGO_URI 6 | import logger 7 | from config.types import Ad, Event, OWData, Subscriber, event_notification_field 8 | from utils import format_phone_number 9 | 10 | timezone = pytz.timezone('Europe/Oslo') 11 | 12 | date_to_events = Dict[event_notification_field, List[Event]] 13 | 14 | class Database: 15 | def __init__(self): 16 | self.db_uri = MONGO_URI 17 | logger.info(f"initializing database at URI {self.db_uri}...") 18 | self.db_client = None 19 | self.db = None 20 | self.is_connected = False 21 | 22 | def __combine_events_with_same_date(self, events: List[Event], date_field: event_notification_field) -> date_to_events: 23 | d2e: date_to_events = {} 24 | for event in events: 25 | date = cast(event_notification_field, event[date_field]) 26 | if date not in d2e: 27 | d2e[date] = [] 28 | d2e[date].append(event) 29 | return d2e 30 | 31 | def __get_todays_events_from_database(self, search_field: event_notification_field) -> date_to_events: 32 | if not self.is_connected: 33 | logger.warning("not connected to database") 34 | return {} 35 | collection = self.db["events"] 36 | current_time = datetime.now(timezone) 37 | tomorrow = current_time + timedelta(days=1) 38 | date_format = "%Y-%m-%dT%H:%M:%S%z" 39 | events = collection.find({ 40 | search_field: { 41 | "$gte": current_time.strftime(date_format), 42 | "$lt": tomorrow.strftime(date_format) 43 | } 44 | }) 45 | return self.__combine_events_with_same_date(events, search_field) 46 | 47 | def connect(self) -> None: 48 | self.db_client = MongoClient(self.db_uri) 49 | db_name = 'dev' if ENVIRONMENT == 'dev' else 'JOBB' 50 | self.db = self.db_client[db_name] 51 | self.is_connected = True 52 | logger.info(f"connected to database {db_name}") 53 | 54 | def disconnect(self) -> None: 55 | self.db_client.close 56 | self.db = None 57 | self.is_connected = False 58 | logger.info("disconnected from database") 59 | 60 | def event_exists_in_database(self, event_id: int) -> bool: 61 | if not self.is_connected: 62 | logger.warning("not connected to database") 63 | return False 64 | collection = self.db["events"] 65 | return collection.find_one({"id": event_id}) is not None 66 | 67 | def add_events_to_database(self, events) -> None: 68 | if not self.is_connected: 69 | logger.warning("not connected to database") 70 | return 71 | if (len(events) == 0): 72 | logger.warning("no events to add") 73 | return 74 | logger.info(f"adding {len(events)} events to database with event IDs: {', '.join([str(event['id']) for event in events])}") 75 | collection = self.db["events"] 76 | collection.insert_many(events, ordered=False) 77 | 78 | def update_events_in_database(self, events: List[Event]) -> None: 79 | if not self.is_connected: 80 | logger.warning("not connected to database") 81 | return 82 | if (len(events) == 0): 83 | logger.warning("no events to update") 84 | return 85 | logger.info(f"updating {len(events)} events in database with event IDs: {', '.join([str(event['id']) for event in events])}") 86 | collection = self.db["events"] 87 | for event in events: 88 | collection.replace_one({"id": event["id"]}, event) 89 | 90 | def get_all_events_from_database(self) -> List[Event]: 91 | if not self.is_connected: 92 | logger.warning("not connected to database") 93 | return [] 94 | collection = self.db["events"] 95 | return list(collection.find()) 96 | 97 | def get_events_by_id(self, event_ids: List[int]) -> List[Event]: 98 | if not self.is_connected: 99 | logger.warning("not connected to database") 100 | return [] 101 | collection = self.db["events"] 102 | return list(collection.find({"id": {"$in": event_ids}})) 103 | 104 | def get_todays_register_events_from_database(self) -> date_to_events: 105 | return self.__get_todays_events_from_database("registration_start") 106 | 107 | def get_todays_unattend_events_from_database(self) -> date_to_events: 108 | return self.__get_todays_events_from_database("unattend_deadline") 109 | 110 | def get_todays_start_events_from_database(self) -> date_to_events: 111 | return self.__get_todays_events_from_database("start_date") 112 | 113 | def subscriber_exists(self, phone_number: str, use_inactive_collection=False) -> bool: 114 | if not self.is_connected: 115 | logger.warning("not connected to database") 116 | return False 117 | collection = self.db["inactive_subscribers" if use_inactive_collection else "subscribers"] 118 | return collection.find_one({"phone_number": phone_number}) is not None 119 | 120 | def get_all_subscribers(self) -> List[Subscriber]: 121 | if not self.is_connected: 122 | logger.warning("not connected to database") 123 | return [] 124 | collection = self.db["subscribers"] 125 | return list(collection.find()) 126 | 127 | def get_subscribers_for_ads(self) -> List[Subscriber]: 128 | if not self.is_connected: 129 | logger.warning("not connected to database") 130 | return [] 131 | collection = self.db["subscribers"] 132 | return list(collection.find({"should_receive_ads": True})) 133 | 134 | def add_subscriber(self, subscriber: Subscriber) -> bool: 135 | if not self.is_connected: 136 | logger.warning("not connected to database") 137 | return False 138 | collection = self.db["subscribers"] 139 | response = collection.update_one( 140 | {"phone_number": subscriber["phone_number"]}, 141 | { "$set": subscriber }, 142 | upsert=True 143 | ) 144 | return response.matched_count == 0 145 | 146 | def re_activate_subscriber(self, phone_number: str) -> bool: 147 | if not self.is_connected: 148 | logger.warning("not connected to database") 149 | return False 150 | inactive_sub_collection = self.db["inactive_subscribers"] 151 | response = inactive_sub_collection.find_one_and_delete({"phone_number": phone_number}) 152 | successfully_deleted = response is not None 153 | if successfully_deleted: 154 | active_sub_collection = self.db["subscribers"] 155 | active_sub_collection.insert_one(response) 156 | return successfully_deleted 157 | 158 | def remove_subscriber(self, phone_number) -> bool: 159 | if not self.is_connected: 160 | logger.warning("not connected to database") 161 | return False 162 | collection = self.db["subscribers"] 163 | response = collection.find_one_and_delete({"phone_number": phone_number}) 164 | successfully_deleted = response is not None 165 | if successfully_deleted: 166 | inactive_collection = self.db["inactive_subscribers"] 167 | inactive_collection.insert_one(response) 168 | return successfully_deleted 169 | 170 | # TODO: see if we can put all this logic in a single MongoDB query, i.e. format the OW phone number in the query 171 | def get_ow_data_for_phone_number(self, phone_number: str) -> Optional[OWData]: 172 | if not self.is_connected: 173 | logger.warning("not connected to database") 174 | return None 175 | collection = self.db["ow_users"] 176 | query = { 177 | "phone_number": { "$ne": None }, 178 | "username": { "$ne": None } 179 | } 180 | ow_users = list(collection.find(query)) 181 | for user in ow_users: 182 | ow_phone_number = user["phone_number"] 183 | if not ow_phone_number: 184 | continue 185 | formatted_phone_number = format_phone_number(ow_phone_number) 186 | if formatted_phone_number == phone_number: 187 | return user 188 | return None 189 | 190 | def add_ow_users(self, ow_users: List[OWData]) -> None: 191 | if not self.is_connected: 192 | logger.warning("not connected to database") 193 | return 194 | if (len(ow_users) == 0): 195 | logger.warning("no ow users to add") 196 | return 197 | logger.info(f"adding {len(ow_users)} ow users to database") 198 | collection = self.db["ow_users"] 199 | collection.insert_many(ow_users, ordered=False) 200 | 201 | def get_active_ads(self) -> List[Ad]: 202 | if not self.is_connected: 203 | logger.warning("not connected to database") 204 | return [] 205 | collection = self.db["ads"] 206 | return list(collection.find({ "is_active": True }).sort("priority_order", 1)) 207 | 208 | def add_new_ad_received(self, ad_key: str, subscriber: Subscriber) -> None: 209 | if not self.is_connected: 210 | logger.warning("not connected to database") 211 | return 212 | collection = self.db["subscribers"] 213 | collection.update_one( 214 | {"_id": subscriber["_id"]}, 215 | { "$push": { "ads_received": ad_key } } 216 | ) 217 | --------------------------------------------------------------------------------