├── 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 |
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 |
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 |
14 | Nytt Brett
15 |
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 |
23 | Fullfør registrering
24 |
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 |
25 | {children}
26 |
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 |
59 |
60 | Logg Inn
61 |
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 |
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 |
12 | }>
13 | {/* @ts-expect-error Server Component */}
14 |
15 |
16 |
17 |
18 |
19 | }>
20 | {/* @ts-expect-error Server Component */}
21 |
22 |
23 |
24 |
25 |
26 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------