From 86d1e11dd1624ff960be7c38ce184211fda96cda Mon Sep 17 00:00:00 2001 From: Robert Eggl Date: Mon, 7 Oct 2024 23:02:43 +0200 Subject: [PATCH] Adds database and mutations (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add university sports mutation and resolver with database integration * refactor: Remove unused university sports mutation and resolvers; add caching for parking, charging, bus, train, and cl-events queries * refactor: Replace create and update university sports mutations with a single upsert mutation * feat: Add upsert and delete mutations for app announcements with GraphQL integration * refactor: Update CI workflow to use Bun for package management and linting * feat: Update app announcements and university sports mutations to use Unix timestamps for date fields * adds jwt check * feat: Enhance authentication handling by assigning default role for unauthenticated users * feat: Update database connection to use Bun environment variables and enhance authorization checks * feat: Integrate graphql-scalars for enhanced date handling in announcements and university sports * feat: Update schema to use EmailAddress scalar and enhance documentation configuration * feat: Refactor linting configuration and improve type safety in scraping and utility functions * Adds Drizzle * feat: Refactor database schema and migrations for app announcements and university sports * feat: Update authentication handling to support multiple roles and adjust related migrations * 🐛 fix deleteUniversitySport * feat: Enhance JWT verification by fetching public key from JWKS endpoint * 🩹 fix token deconstruction * feat: Add created_at and updated_at fields to app_announcements and update related migrations * feat: Deprecate announcements query and introduce appAnnouncements query with updated timestamps * 🔧 update misc files * 🐛 fix food API * 📦 update bun.lockb * fix: Update organizer formatting to handle 'e. V.' abbreviation --------- Co-authored-by: Philipp Opheys --- .dockerignore | 12 + .env.local.example | 7 +- .eslintrc.json | 17 - .github/workflows/lint.yml | 14 +- .gitignore | 1 + Dockerfile | 2 +- bun.lockb | Bin 204962 -> 209238 bytes docker-compose.yml | 2 - documentation/config.yml | 6 +- documentation/generated/index.html | 1728 +++++++++++++++++---- eslint.config.js | 22 + index.ts | 31 +- package.json | 30 +- src/db/index.ts | 10 + src/db/migrate.ts | 17 + src/db/migrations/0000_round_scream.sql | 41 + src/db/migrations/0001_free_midnight.sql | 2 + src/db/migrations/meta/0000_snapshot.json | 201 +++ src/db/migrations/meta/0001_snapshot.json | 213 +++ src/db/migrations/meta/_journal.json | 20 + src/db/schema/appAnnouncements.ts | 38 + src/db/schema/index.ts | 4 + src/db/schema/universitySports.ts | 17 + src/mutations/app-announcements/delete.ts | 34 + src/mutations/app-announcements/upsert.ts | 78 + src/mutations/university-sports/delete.ts | 34 + src/mutations/university-sports/upsert.ts | 90 ++ src/queries/appAnnouncements.ts | 24 + src/{resolvers => queries}/bus.ts | 8 +- src/{resolvers => queries}/charging.ts | 3 +- src/{resolvers => queries}/cl-events.ts | 3 +- src/{resolvers => queries}/food.ts | 7 +- src/{resolvers => queries}/parking.ts | 3 +- src/queries/sports.ts | 28 + src/{resolvers => queries}/train.ts | 5 +- src/resolvers.ts | 42 + src/resolvers/announcements.ts | 52 - src/resolvers/index.ts | 19 - src/schema.gql | 211 ++- src/scraping/bus.ts | 8 +- src/scraping/charging.ts | 1 + src/scraping/cl-event.ts | 18 +- src/scraping/mensa.ts | 9 +- src/scraping/train.ts | 10 +- src/types/announcement.d.ts | 19 +- src/types/food.d.ts | 7 + src/types/sports.d.ts | 51 + src/utils/auth-utils.ts | 26 + src/utils/date-utils.ts | 19 +- src/utils/food-utils.ts | 4 +- src/utils/translation-utils.ts | 11 +- tsconfig.json | 3 +- 52 files changed, 2821 insertions(+), 441 deletions(-) create mode 100644 .dockerignore delete mode 100644 .eslintrc.json create mode 100644 eslint.config.js create mode 100644 src/db/index.ts create mode 100644 src/db/migrate.ts create mode 100644 src/db/migrations/0000_round_scream.sql create mode 100644 src/db/migrations/0001_free_midnight.sql create mode 100644 src/db/migrations/meta/0000_snapshot.json create mode 100644 src/db/migrations/meta/0001_snapshot.json create mode 100644 src/db/migrations/meta/_journal.json create mode 100644 src/db/schema/appAnnouncements.ts create mode 100644 src/db/schema/index.ts create mode 100644 src/db/schema/universitySports.ts create mode 100644 src/mutations/app-announcements/delete.ts create mode 100644 src/mutations/app-announcements/upsert.ts create mode 100644 src/mutations/university-sports/delete.ts create mode 100644 src/mutations/university-sports/upsert.ts create mode 100644 src/queries/appAnnouncements.ts rename src/{resolvers => queries}/bus.ts (72%) rename src/{resolvers => queries}/charging.ts (91%) rename src/{resolvers => queries}/cl-events.ts (93%) rename src/{resolvers => queries}/food.ts (96%) rename src/{resolvers => queries}/parking.ts (90%) create mode 100644 src/queries/sports.ts rename src/{resolvers => queries}/train.ts (90%) create mode 100644 src/resolvers.ts delete mode 100644 src/resolvers/announcements.ts delete mode 100644 src/resolvers/index.ts create mode 100644 src/types/sports.d.ts create mode 100644 src/utils/auth-utils.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e7c3d29 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.env +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git +/data/* +.huksy +.github +.vscode \ No newline at end of file diff --git a/.env.local.example b/.env.local.example index e28a786..7498ed9 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,3 +1,8 @@ DEEPL_API_KEY="" MOODLE_USERNAME="" -MOODLE_PASSWORD="" \ No newline at end of file +MOODLE_PASSWORD="" +DB_HOST=localhost +DB_PORT=5432 +POSTGRES_DB=app +POSTGRES_USER=postgres +POSTGRES_PASSWORD="" \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bf64e90..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "extends": ["eslint:recommended", "standard-with-typescript", "prettier"], - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module", - "project": "**/tsconfig.json", - "tsconfigRootDir": "./", - "noUnusedLocals": true, - "noUnusedParameters": true - }, - "rules": {}, - "ignorePatterns": ["node_modules", "documentation"] -} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 38d81b5..7603216 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,20 +1,20 @@ name: CI on: push: - branches: ['main', 'develop'] + branches: [develop, main] pull_request: + branches: [develop, main] jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 + - uses: oven-sh/setup-bun@v2 with: - node-version: 20 + bun-version: latest + - uses: actions/checkout@v4 - name: Install modules - run: npm install + run: bun install - name: Run ESLint - run: npx eslint . --ext .js,.jsx,.ts,.tsx + run: bunx eslint . - name: Run Prettier run: npx prettier --check . diff --git a/.gitignore b/.gitignore index f20e809..458e05e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules .DS_Store src/data/announcements.json src/data/cl-events.json +database/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9310875..8929f9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # use the official Bun image # see all versions at https://hub.docker.com/r/oven/bun/tags -FROM oven/bun:1 as base +FROM oven/bun:1 AS base WORKDIR /usr/src/app # install dependencies into temp directory diff --git a/bun.lockb b/bun.lockb index de3c08cb814bb796cb51b162ada7d6e79137981c..839a7a4a8af04b71f7241afa26ef3fc7dc7482a9 100755 GIT binary patch delta 62757 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 LIRD$zZ1evEH&)G0 delta 60988 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 + + -
  • @@ -52,18 +64,24 @@ @@ -116,9 +138,16 @@
    API Endpoints

    Queries

    -

    +

    announcements

    +
    +
    +
    +
    Use appAnnouncements instead
    +
    +
    +
    @@ -154,6 +183,8 @@
    Query
    endDateTime priority url + createdAt + updatedAt } } @@ -171,10 +202,90 @@
    Response
    "id": 4, "title": MultiLanguageString, "description": MultiLanguageString, - "startDateTime": "xyz789", - "endDateTime": "xyz789", + "startDateTime": "2007-12-03T10:15:30Z", + "endDateTime": "2007-12-03T10:15:30Z", + "priority": 123, + "url": "xyz789", + "createdAt": "2007-12-03T10:15:30Z", + "updatedAt": "2007-12-03T10:15:30Z" + } + ] + } +} + + + +
    +
    +
    +
    +
    +
    + Queries +
    +

    + appAnnouncements +

    +
    +
    +
    +
    Description
    +

    Get the current in app announcements

    +
    +
    +
    +
    +
    +
    +
    Response
    +

    Returns [Announcement!]! +

    +
    +
    +
    +

    Example

    +
    +
    Query
    + + +
    query appAnnouncements {
    +  appAnnouncements {
    +    id
    +    title {
    +      ...MultiLanguageStringFragment
    +    }
    +    description {
    +      ...MultiLanguageStringFragment
    +    }
    +    startDateTime
    +    endDateTime
    +    priority
    +    url
    +    createdAt
    +    updatedAt
    +  }
    +}
    +
    + + +
    +
    +
    Response
    + + +
    {
    +  "data": {
    +    "appAnnouncements": [
    +      {
    +        "id": 4,
    +        "title": MultiLanguageString,
    +        "description": MultiLanguageString,
    +        "startDateTime": "2007-12-03T10:15:30Z",
    +        "endDateTime": "2007-12-03T10:15:30Z",
             "priority": 987,
    -        "url": "xyz789"
    +        "url": "xyz789",
    +        "createdAt": "2007-12-03T10:15:30Z",
    +        "updatedAt": "2007-12-03T10:15:30Z"
           }
         ]
       }
    @@ -264,7 +375,7 @@ 
    Response
    "bus": [ { "route": "abc123", - "destination": "xyz789", + "destination": "abc123", "time": "xyz789" } ] @@ -336,12 +447,12 @@
    Response
    "name": "abc123", "address": "abc123", "city": "xyz789", - "latitude": 987.65, + "latitude": 123.45, "longitude": 987.65, "available": 123, - "total": 123, - "freeParking": false, - "operator": "xyz789" + "total": 987, + "freeParking": true, + "operator": "abc123" } ] } @@ -406,12 +517,12 @@
    Response
    "clEvents": [ { "id": "4", - "organizer": "abc123", - "title": "abc123", - "begin": "xyz789", + "organizer": "xyz789", + "title": "xyz789", + "begin": "abc123", "end": "abc123", - "location": "xyz789", - "description": "xyz789" + "location": "abc123", + "description": "abc123" } ] } @@ -490,7 +601,7 @@
    Query
    Variables
    -
    {"locations": ["abc123"]}
    +                    
    {"locations": ["xyz789"]}
     
    @@ -562,7 +673,7 @@
    Response
    {
       "data": {
         "parking": {
    -      "updated": "xyz789",
    +      "updated": "abc123",
           "lots": [ParkingLot]
         }
       }
    @@ -642,7 +753,7 @@ 
    Query
    Variables
    -
    {"station": "xyz789"}
    +                    
    {"station": "abc123"}
     
    @@ -656,12 +767,12 @@
    Response
    "train": [ { "name": "xyz789", - "destination": "abc123", + "destination": "xyz789", "plannedTime": "abc123", "actualTime": "xyz789", "canceled": false, - "track": "abc123", - "url": "xyz789" + "track": "xyz789", + "url": "abc123" } ] } @@ -673,77 +784,84 @@
    Response
    -

    Types

    -
    -

    Announcement

    +
    +
    + Queries +
    +

    + universitySports +

    -
    +
    Description
    -

    Announcement data to display on top of the apps dashboard

    +

    Get the university sports events

    -
    -
    Fields
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Field NameDescription
    id - ID! - Unique identifier of the announcement
    title - MultiLanguageString! - Title of the announcement in different languages
    description - MultiLanguageString! - Description of the announcement in different languages
    startDateTime - String! - Start date and time when the announcement is displayed
    endDateTime - String! - End date and time when the announcement is displayed
    priority - Int! - Priority of the announcement, higher are more important
    url - String - URL to the announcement
    +
    +
    +
    +
    +
    +
    Response
    +

    Returns [UniversitySports!] +

    -
    -
    Example
    +

    Example

    +
    +
    Query
    + + +
    query universitySports {
    +  universitySports {
    +    id
    +    title {
    +      ...MultiLanguageStringFragment
    +    }
    +    description {
    +      ...MultiLanguageStringFragment
    +    }
    +    campus
    +    location
    +    weekday
    +    startTime
    +    endTime
    +    requiresRegistration
    +    invitationLink
    +    eMail
    +    createdAt
    +    updatedAt
    +  }
    +}
    +
    + + +
    +
    +
    Response
    {
    -  "id": "4",
    -  "title": MultiLanguageString,
    -  "description": MultiLanguageString,
    -  "startDateTime": "xyz789",
    -  "endDateTime": "abc123",
    -  "priority": 987,
    -  "url": "abc123"
    +  "data": {
    +    "universitySports": [
    +      {
    +        "id": 4,
    +        "title": MultiLanguageString,
    +        "description": MultiLanguageString,
    +        "campus": "Ingolstadt",
    +        "location": "abc123",
    +        "weekday": "Monday",
    +        "startTime": "24:00:00",
    +        "endTime": "24:00:00",
    +        "requiresRegistration": true,
    +        "invitationLink": "abc123",
    +        "eMail": "test@test.com",
    +        "createdAt": "2007-12-03T10:15:30Z",
    +        "updatedAt": "2007-12-03T10:15:30Z"
    +      }
    +    ]
    +  }
     }
     
    @@ -752,72 +870,74 @@
    Example
    -
    -
    - Types -
    -

    Boolean

    +

    Mutations

    +
    +

    + deleteAppAnnouncement +

    -
    +
    Description
    -

    The Boolean scalar type represents true or false.

    +

    Delete an announcement by ID

    -
    -
    -
    -
    -
    -
    - Types
    -

    Bus

    -
    -
    Description
    -

    Charging station data

    +
    +
    Response
    +

    Returns a Boolean +

    -
    -
    Fields
    +
    +
    Arguments
    - + - - - - - - - - - -
    Field NameName Description
    route - String! - Code of the bus route, like 10, N1, etc.
    destination - String! + + id - ID! Destination of the bus route
    time - String! + Planned time at the station
    -
    -
    Example
    +

    Example

    +
    +
    Query
    -
    {
    -  "route": "abc123",
    -  "destination": "abc123",
    -  "time": "xyz789"
    -}
    +                    
    mutation deleteAppAnnouncement($id: ID!) {
    +  deleteAppAnnouncement(id: $id)
    +}
    +
    + + +
    +
    +
    Variables
    + + +
    {"id": "4"}
    +
    + + +
    +
    +
    Response
    + + +
    {"data": {"deleteAppAnnouncement": true}}
     
    @@ -825,11 +945,572 @@
    Example
    -
    -
    - Types +
    + -

    ChargingStation

    +

    + deleteUniversitySport +

    +
    +
    +
    +
    Description
    +

    Delete a university sports event by ID

    +
    +
    +
    +
    +
    +
    +
    Response
    +

    Returns a Boolean +

    +
    +
    +
    Arguments
    + + + + + + + + + + + + + +
    NameDescription
    + id - ID! + +
    +
    +
    +
    +

    Example

    +
    +
    Query
    + + +
    mutation deleteUniversitySport($id: ID!) {
    +  deleteUniversitySport(id: $id)
    +}
    +
    + + +
    +
    +
    Variables
    + + +
    {"id": 4}
    +
    + + +
    +
    +
    Response
    + + +
    {"data": {"deleteUniversitySport": false}}
    +
    + + +
    +
    +
    +
    +
    +
    + Mutations +
    +

    + upsertAppAnnouncement +

    +
    +
    +
    +
    Description
    +

    Create or update an announcement. If an ID is provided, the announcement is updated, otherwise a new announcement is created.

    +
    +
    +
    +
    +
    +
    +
    Response
    +

    Returns an UpsertResponse +

    +
    +
    +
    Arguments
    + + + + + + + + + + + + + + + + + +
    NameDescription
    + id - ID + +
    + input - AnnouncementInput! + +
    +
    +
    +
    +

    Example

    +
    +
    Query
    + + +
    mutation upsertAppAnnouncement(
    +  $id: ID,
    +  $input: AnnouncementInput!
    +) {
    +  upsertAppAnnouncement(
    +    id: $id,
    +    input: $input
    +  ) {
    +    id
    +  }
    +}
    +
    + + +
    +
    +
    Variables
    + + +
    {
    +  "id": "4",
    +  "input": AnnouncementInput
    +}
    +
    + + +
    +
    +
    Response
    + + +
    {"data": {"upsertAppAnnouncement": {"id": 4}}}
    +
    + + +
    +
    +
    +
    +
    +
    + Mutations +
    +

    + upsertUniversitySport +

    +
    +
    +
    +
    Description
    +

    Create or update a university sports event. If an ID is provided, the event is updated, otherwise a new event is created.

    +
    +
    +
    +
    +
    +
    +
    Response
    +

    Returns an UpsertResponse +

    +
    +
    +
    Arguments
    + + + + + + + + + + + + + + + + + +
    NameDescription
    + id - ID + +
    + input - UniversitySportsInput! + +
    +
    +
    +
    +

    Example

    +
    +
    Query
    + + +
    mutation upsertUniversitySport(
    +  $id: ID,
    +  $input: UniversitySportsInput!
    +) {
    +  upsertUniversitySport(
    +    id: $id,
    +    input: $input
    +  ) {
    +    id
    +  }
    +}
    +
    + + +
    +
    +
    Variables
    + + +
    {"id": 4, "input": UniversitySportsInput}
    +
    + + +
    +
    +
    Response
    + + +
    {"data": {"upsertUniversitySport": {"id": 4}}}
    +
    + + +
    +
    +
    +
    +

    Types

    +
    +

    Announcement

    +
    +
    +
    +
    Description
    +

    Announcement data to display on top of the apps dashboard

    +
    +
    +
    Fields
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Field NameDescription
    id - ID! + Unique identifier of the announcement
    title - MultiLanguageString! + Title of the announcement in different languages
    description - MultiLanguageString! + Description of the announcement in different languages
    startDateTime - DateTime! + Start date and time when the announcement is displayed
    endDateTime - DateTime! + End date and time when the announcement is displayed
    priority - Int! + Priority of the announcement, higher are more important
    url - String + URL to the announcement
    createdAt - DateTime! + Creation date of the announcement
    updatedAt - DateTime! + Last update date of the announcement
    +
    +
    +
    +
    +
    Example
    + + +
    {
    +  "id": "4",
    +  "title": MultiLanguageString,
    +  "description": MultiLanguageString,
    +  "startDateTime": "2007-12-03T10:15:30Z",
    +  "endDateTime": "2007-12-03T10:15:30Z",
    +  "priority": 123,
    +  "url": "xyz789",
    +  "createdAt": "2007-12-03T10:15:30Z",
    +  "updatedAt": "2007-12-03T10:15:30Z"
    +}
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    AnnouncementInput

    +
    +
    +
    +
    Description
    +

    Input type for the announcement

    +
    +
    +
    Fields
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Input FieldDescription
    + title - MultiLanguageStringInput! + Title of the announcement in different languages
    + description - MultiLanguageStringInput! + Description of the announcement in different languages
    + startDateTime - DateTime! + Start date and time when the announcement is displayed
    + endDateTime - DateTime! + End date and time when the announcement is displayed
    + priority - Int! + Priority of the announcement, higher are more important
    + url - String + URL to the announcement
    +
    +
    +
    +
    +
    Example
    + + +
    {
    +  "title": MultiLanguageStringInput,
    +  "description": MultiLanguageStringInput,
    +  "startDateTime": "2007-12-03T10:15:30Z",
    +  "endDateTime": "2007-12-03T10:15:30Z",
    +  "priority": 987,
    +  "url": "abc123"
    +}
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    Boolean

    +
    +
    +
    +
    Description
    +

    The Boolean scalar type represents true or false.

    +
    +
    +
    +
    +
    +
    +
    +
    + Types +
    +

    Bus

    +
    +
    +
    +
    Description
    +

    Charging station data

    +
    +
    +
    Fields
    + + + + + + + + + + + + + + + + + + + + + +
    Field NameDescription
    route - String! + Code of the bus route, like 10, N1, etc.
    destination - String! + Destination of the bus route
    time - String! + Planned time at the station
    +
    +
    +
    +
    +
    Example
    + + +
    {
    +  "route": "xyz789",
    +  "destination": "abc123",
    +  "time": "abc123"
    +}
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    CampusType

    +
    +
    +
    +
    Description
    +

    Enum representing the different locations of THI.

    +
    +
    +
    Values
    + + + + + + + + + + + + + + + + + +
    Enum ValueDescription
    +

    Ingolstadt

    +
    +
    +

    Neuburg

    +
    +
    +
    +
    +
    +
    +
    Example
    + + +
    "Ingolstadt"
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    ChargingStation

    @@ -906,13 +1587,13 @@
    Example
    {
    -  "id": 987,
    +  "id": 123,
       "name": "xyz789",
    -  "address": "xyz789",
    -  "city": "abc123",
    +  "address": "abc123",
    +  "city": "xyz789",
       "latitude": 123.45,
    -  "longitude": 123.45,
    -  "available": 123,
    +  "longitude": 987.65,
    +  "available": 987,
       "total": 123,
       "freeParking": false,
       "operator": "xyz789"
    @@ -990,14 +1671,64 @@ 
    Example
    {
    -  "id": 4,
    +  "id": "4",
       "organizer": "abc123",
    -  "title": "abc123",
    +  "title": "xyz789",
       "begin": "abc123",
    -  "end": "xyz789",
    -  "location": "abc123",
    +  "end": "abc123",
    +  "location": "xyz789",
       "description": "abc123"
     }
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    DateTime

    +
    +
    +
    +
    Description
    +

    A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the date-time format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar.

    +
    +
    +
    +
    +
    Example
    + + +
    "2007-12-03T10:15:30Z"
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    EmailAddress

    +
    +
    +
    +
    Description
    +

    A field whose value conforms to the standard internet email address format as specified in HTML Spec: https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address.

    +
    +
    +
    +
    +
    Example
    + + +
    "test@test.com"
     
    @@ -1071,7 +1802,7 @@
    Example
    {
    -  "timestamp": "abc123",
    +  "timestamp": "xyz789",
       "meals": [Meal]
     }
     
    @@ -1122,7 +1853,7 @@
    Example
    {
    -  "location": "abc123",
    +  "location": "xyz789",
       "message": "abc123"
     }
     
    @@ -1196,7 +1927,7 @@
    Description
    Example
    -
    "4"
    +                    
    4
     
    @@ -1222,6 +1953,31 @@
    Example
    123
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    LocalEndTime

    +
    +
    +
    +
    Description
    +

    A local time string (i.e., with no associated timezone) in 24-hr HH:mm[:ss[.SSS]] format, e.g. 14:25 or 14:25:06 or 14:25:06.123. This scalar is very similar to the LocalTime, with the only difference being that LocalEndTime also allows 24:00 as a valid value to indicate midnight of the following day. This is useful when using the scalar to represent the exclusive upper bound of a time block.

    +
    +
    +
    +
    +
    Example
    + + +
    "24:00:00"
     
    @@ -1301,9 +2057,69 @@
    Fields
    Static meals are always available, non-static meals are only available on specific days - restaurant - String! + restaurant - String! + + Restaurant where the meal is available (IngolstadtMensa, NeuburgMensa, Reimanns, Canisius) + + + +
    +
    +
    +
    +
    Example
    + + +
    {
    +  "name": MultiLanguageString,
    +  "id": 4,
    +  "category": "xyz789",
    +  "prices": Prices,
    +  "allergens": ["abc123"],
    +  "flags": ["xyz789"],
    +  "nutrition": Nutrition,
    +  "variants": [Variation],
    +  "originalLanguage": "de",
    +  "static": true,
    +  "restaurant": "abc123"
    +}
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    MultiLanguageString

    +
    +
    +
    +
    Description
    +

    String in multiple languages (German and English)

    +
    +
    +
    Fields
    + + + + + + + + + + + + + + - +
    Field NameDescription
    de - String + German language code
    en - String Restaurant where the meal is available (IngolstadtMensa, NeuburgMensa, Reimanns, Canisius) English language code
    @@ -1315,17 +2131,8 @@
    Example
    {
    -  "name": MultiLanguageString,
    -  "id": 4,
    -  "category": "xyz789",
    -  "prices": Prices,
    -  "allergens": ["abc123"],
    -  "flags": ["xyz789"],
    -  "nutrition": Nutrition,
    -  "variants": [Variation],
    -  "originalLanguage": "de",
    -  "static": false,
    -  "restaurant": "xyz789"
    +  "de": "xyz789",
    +  "en": "abc123"
     }
     
    @@ -1334,36 +2141,36 @@
    Example
    -
    +
    -

    MultiLanguageString

    +

    MultiLanguageStringInput

    -
    -
    Description
    -

    String in multiple languages (German and English)

    -
    Fields
    - + - + - - + -
    Field NameInput Field Description
    de - String + + en - String! + German language code
    en - String + + de - String! + English language code
    @@ -1375,8 +2182,8 @@
    Example
    {
    -  "de": "abc123",
    -  "en": "xyz789"
    +  "en": "abc123",
    +  "de": "abc123"
     }
     
    @@ -1461,13 +2268,13 @@
    Example
    {
    -  "kj": 123.45,
    +  "kj": 987.65,
       "kcal": 987.65,
       "fat": 987.65,
    -  "fatSaturated": 123.45,
    +  "fatSaturated": 987.65,
       "carbs": 987.65,
    -  "sugar": 987.65,
    -  "fiber": 987.65,
    +  "sugar": 123.45,
    +  "fiber": 123.45,
       "protein": 123.45,
       "salt": 123.45
     }
    @@ -1574,7 +2381,7 @@ 
    Example
    {
    -  "id": "4",
    +  "id": 4,
       "category": "xyz789",
       "name": MultiLanguageString
     }
    @@ -1601,20 +2408,249 @@ 
    Fields
    - - + + + + + + + + + + + + + + +
    Field NameDescriptionField NameDescription
    updated - String! + Timestamp of the last update from the source
    lots - [ParkingLot!]! + List of parking lots
    +
    +
    +
    +
    +
    Example
    + + +
    {
    +  "updated": "xyz789",
    +  "lots": [ParkingLot]
    +}
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    ParkingLot

    +
    +
    +
    +
    Description
    +

    Parking lot data

    +
    +
    +
    Fields
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Field NameDescription
    name - String! + Name of the parking lot
    category - String! + Category of the parking lot (parking garage, parking lot, etc.)
    available - Int! + Number of available parking spaces
    total - Int! + Total number of parking spaces
    tendency - Int + tendency of the parking lot (-1 : decreasing, 0 : stable, 1 : increasing) or null if not available
    priceLevel - Int + Static price level of the parking lot between 0 (free) and 3 (expensive) or null if not available
    +
    +
    +
    +
    +
    Example
    + + +
    {
    +  "name": "xyz789",
    +  "category": "xyz789",
    +  "available": 123,
    +  "total": 987,
    +  "tendency": 987,
    +  "priceLevel": 987
    +}
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    Prices

    +
    +
    +
    +
    Description
    +

    Prices for different types of customers

    +
    +
    +
    Fields
    + + + + + + + + + + + + + + + + + + + + + +
    Field NameDescription
    student - Float + Price for students
    employee - Float + Price for employees
    guest - Float + Price for guests
    +
    +
    +
    +
    +
    Example
    + + +
    {"student": 987.65, "employee": 123.45, "guest": 123.45}
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    String

    +
    +
    +
    +
    Description
    +

    The String scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.

    +
    +
    +
    +
    +
    Example
    + + +
    "abc123"
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    Train

    +
    +
    +
    +
    Description
    +

    Train data

    +
    +
    +
    Fields
    + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + - - +
    Field NameDescription
    name - String! + Name of the train
    destination - String! + Destination of the train
    plannedTime - String + Planned departure time
    actualTime - String + Actual departure time
    canceled - Boolean! + True if the train is canceled
    updated - String! + track - String Timestamp of the last update from the source Track of the train
    lots - [ParkingLot!]! + url - String List of parking lots URL to the train information
    @@ -1626,8 +2662,13 @@
    Example
    {
    -  "updated": "abc123",
    -  "lots": [ParkingLot]
    +  "name": "abc123",
    +  "destination": "abc123",
    +  "plannedTime": "abc123",
    +  "actualTime": "xyz789",
    +  "canceled": true,
    +  "track": "abc123",
    +  "url": "xyz789"
     }
     
    @@ -1636,16 +2677,16 @@
    Example
    -
    +
    -

    ParkingLot

    +

    UniversitySports

    Description
    -

    Parking lot data

    +

    University sports event data

    Fields
    @@ -1658,34 +2699,69 @@
    Fields
    - name - String! + id - ID! - Name of the parking lot + Unique identifier of the sports event - category - String! + title - MultiLanguageString! - Category of the parking lot (parking garage, parking lot, etc.) + Title of the sports event in different languages - available - Int! + description - MultiLanguageString - Number of available parking spaces + Description of the sports event in different languages - total - Int! + campus - CampusType! - Total number of parking spaces + Campus where the sports event belongs to. This is not the location of the event itself. - tendency - Int + location - String! - tendency of the parking lot (-1 : decreasing, 0 : stable, 1 : increasing) or null if not available + Location of the sports event - priceLevel - Int + weekday - WeekdayType! - Static price level of the parking lot between 0 (free) and 3 (expensive) or null if not available + Weekday of the sports event + + + startTime - LocalEndTime! + + Start time of the sports event + + + endTime - LocalEndTime + + End time of the sports event + + + requiresRegistration - Boolean! + + True if the sports event requires registration + + + invitationLink - String + + Invitation link for the sports event, e.g. a WhatsApp group + + + eMail - EmailAddress + + E-Mail address for registration or contact + + + createdAt - DateTime! + + Creation date of the sports event + + + updatedAt - DateTime! + + Last update date of the sports event @@ -1697,12 +2773,19 @@
    Example
    {
    -  "name": "xyz789",
    -  "category": "abc123",
    -  "available": 987,
    -  "total": 123,
    -  "tendency": 123,
    -  "priceLevel": 123
    +  "id": 4,
    +  "title": MultiLanguageString,
    +  "description": MultiLanguageString,
    +  "campus": "Ingolstadt",
    +  "location": "xyz789",
    +  "weekday": "Monday",
    +  "startTime": "24:00:00",
    +  "endTime": "24:00:00",
    +  "requiresRegistration": true,
    +  "invitationLink": "xyz789",
    +  "eMail": "test@test.com",
    +  "createdAt": "2007-12-03T10:15:30Z",
    +  "updatedAt": "2007-12-03T10:15:30Z"
     }
     
    @@ -1711,41 +2794,86 @@
    Example
    -
    +
    -

    Prices

    +

    UniversitySportsInput

    Description
    -

    Prices for different types of customers

    +

    Input type for the university sports event

    Fields
    - + - - + - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Field NameInput Field Description
    student - Float + + title - MultiLanguageStringInput! Price for students Title of the sports event in different languages
    employee - Float + + description - MultiLanguageStringInput Price for employees Description of the sports event in different languages
    guest - Float + + campus - CampusType! Price for guests Campus where the sports event belongs to. This is not the location of the event itself.
    + location - String! + Location of the sports event
    + weekday - WeekdayType! + Weekday of the sports event
    + startTime - LocalEndTime! + Start time of the sports event as Unix timestamp
    + endTime - LocalEndTime + End time of the sports event as Unix timestamp
    + requiresRegistration - Boolean! + True if the sports event requires registration
    + invitationLink - String + Invitation link for the sports event, e.g. a WhatsApp group
    + eMail - EmailAddress + E-Mail address for registration or contact
    @@ -1756,32 +2884,18 @@
    Fields
    Example
    -
    {"student": 123.45, "employee": 987.65, "guest": 123.45}
    -
    - - -
    -
    -
    -
    -
    -
    - Types -
    -

    String

    -
    -
    -
    -
    Description
    -

    The String scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.

    -
    -
    -
    -
    -
    Example
    - - -
    "abc123"
    +                    
    {
    +  "title": MultiLanguageStringInput,
    +  "description": MultiLanguageStringInput,
    +  "campus": "Ingolstadt",
    +  "location": "abc123",
    +  "weekday": "Monday",
    +  "startTime": "24:00:00",
    +  "endTime": "24:00:00",
    +  "requiresRegistration": true,
    +  "invitationLink": "xyz789",
    +  "eMail": "test@test.com"
    +}
     
    @@ -1789,17 +2903,13 @@
    Example
    -
    +
    -

    Train

    +

    UpsertResponse

    -
    -
    Description
    -

    Train data

    -
    Fields
    @@ -1811,39 +2921,10 @@
    Fields
    - - - - - - - - - - - - - - - - - - - - - - - - - -
    name - String! - Name of the train
    destination - String! - Destination of the train
    plannedTime - String - Planned departure time
    actualTime - String - Actual departure time
    canceled - Boolean! - True if the train is canceled
    track - String + id - ID Track of the train
    url - String + URL to the train information
    @@ -1854,15 +2935,7 @@
    Fields
    Example
    -
    {
    -  "name": "xyz789",
    -  "destination": "xyz789",
    -  "plannedTime": "xyz789",
    -  "actualTime": "abc123",
    -  "canceled": true,
    -  "track": "xyz789",
    -  "url": "xyz789"
    -}
    +                    
    {"id": "4"}
     
    @@ -1957,17 +3030,104 @@
    Example
    {
       "name": MultiLanguageString,
    -  "additional": true,
    +  "additional": false,
       "prices": Prices,
       "id": "4",
    -  "allergens": ["xyz789"],
    -  "flags": ["abc123"],
    +  "allergens": ["abc123"],
    +  "flags": ["xyz789"],
       "nutrition": Nutrition,
       "originalLanguage": "de",
       "static": true,
       "restaurant": "xyz789",
       "parent": Parent
     }
    +
    + + +
    +
    +
    +
    +
    +
    + Types +
    +

    WeekdayType

    +
    +
    +
    +
    Description
    +

    Enum representing the different weekdays.

    +
    +
    +
    Values
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Enum ValueDescription
    +

    Monday

    +
    +
    +

    Tuesday

    +
    +
    +

    Wednesday

    +
    +
    +

    Thursday

    +
    +
    +

    Friday

    +
    +
    +

    Saturday

    +
    +
    +

    Sunday

    +
    +
    +
    +
    +
    +
    +
    Example
    + + +
    "Monday"
     
    diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ce4a78a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,22 @@ +import pluginJs from '@eslint/js' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default [ + { ignores: ['**/node_modules', '**/documentation'] }, + { files: ['**/*.{js,mjs,cjs,ts}'] }, + { languageOptions: { globals: globals.browser } }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + tsconfigRootDir: './', + noUnusedLocals: true, + noUnusedParameters: true, + }, + }, + }, +] diff --git a/index.ts b/index.ts index c5065f8..903437c 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,4 @@ +import { getUserFromToken } from '@/utils/auth-utils' import { ApolloServer } from '@apollo/server' import { expressMiddleware } from '@apollo/server/express4' import { @@ -7,12 +8,15 @@ import { import cors from 'cors' import express from 'express' import { readFileSync } from 'fs' +import type { JwtPayload } from 'jsonwebtoken' import NodeCache from 'node-cache' import path from 'path' import { resolvers } from './src/resolvers' -const typeDefs = readFileSync('./src/schema.gql', { encoding: 'utf-8' }) +const schema = readFileSync('./src/schema.gql', { encoding: 'utf-8' }) +const typeDefs = schema +const port = process.env.PORT || 4000 const app = express() app.use( @@ -36,15 +40,32 @@ const apolloServer = new ApolloServer({ }) : ApolloServerPluginLandingPageLocalDefault(), ], + introspection: Bun.env.NODE_ENV !== 'production', }) export const cache = new NodeCache({ stdTTL: 60 * 10 }) // 10 minutes default TTL + await apolloServer.start() app.use('/', express.static(path.join(__dirname, 'documentation/generated'))) +app.use( + '/graphql', + cors(), + express.json(), + expressMiddleware(apolloServer, { + context: async ({ req }): Promise<{ jwtPayload?: JwtPayload }> => { + const authHeader = req.headers.authorization + if (authHeader) { + return { + jwtPayload: await getUserFromToken(authHeader), + } + } else { + return {} + } + }, + }) +) -app.use('/graphql', cors(), express.json(), expressMiddleware(apolloServer)) - -app.listen(4000, () => { - console.log('🚀 Server ready at http://localhost:4000/graphql') +app.listen(port, () => { + console.log('🚀 Server ready at http://localhost:' + port + '/graphql') }) diff --git a/package.json b/package.json index cd60676..a45baa1 100644 --- a/package.json +++ b/package.json @@ -6,46 +6,58 @@ "start": "bun run index.ts", "dev": "bun --hot run index.ts", "prepare": "husky", - "docs": "bunx spectaql ./documentation/config.yml" + "docs": "concurrently --kill-others --success first \"cross-env PORT=4321 bun run start\" \"bunx spectaql ./documentation/config.yml\"", + "drizzle-kit": "drizzle-kit generate --schema ./src/db/schema/ --dialect postgresql --out ./src/db/migrations", + "migrate": "bun run ./src/db/migrate.ts" }, "peerDependencies": { "typescript": "^5.0.0" }, "dependencies": { "@apollo/server": "^4.11.0", - "@types/node": "^20.16.10", "axios": "^1.7.7", "cheerio": "^1.0.0", "deepl": "^1.0.13", + "drizzle-orm": "^0.33.0", "fetch-cookie": "^3.0.1", "graphql": "^16.9.0", + "graphql-scalars": "^1.23.0", "graphql-tag": "^2.12.6", + "jsonwebtoken": "^9.0.2", + "jwk-to-pem": "^2.0.6", "moment": "^2.30.1", "moment-timezone": "^0.5.45", "node-cache": "^5.1.2", "object-hash": "^3.0.0", "pdf-parse": "https://github.com/neuland-ingolstadt/pdf-parse.git", + "postgres": "^3.4.4", "sanitize-html": "^2.13.1", "xml-js": "^1.6.11" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.11.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/bun": "^1.1.10", "@types/cors": "^2.8.17", + "@types/jsonwebtoken": "^9.0.7", + "@types/jwk-to-pem": "^2.0.3", + "@types/node": "^22.7.4", "@types/object-hash": "^3.0.6", "@types/pdf-parse": "^1.1.4", "@types/sanitize-html": "^2.13.0", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "eslint": "^8.57.1", + "@typescript-eslint/eslint-plugin": "^8.8.0", + "concurrently": "^9.0.1", + "cross-env": "^7.0.3", + "drizzle-kit": "^0.24.2", + "eslint": "^9.11.1", "eslint-config-prettier": "^9.1.0", - "eslint-config-standard-with-typescript": "^43.0.1", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-n": "^16.6.2", - "eslint-plugin-promise": "^6.6.0", + "globals": "^15.10.0", "husky": "^9.1.6", "lint-staged": "^15.2.10", "prettier": "^3.3.3", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "typescript-eslint": "^8.8.0" }, "lint-staged": { "**/*.{gql,graphql}": [ diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..9d517f8 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,10 @@ +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' + +import schema from './schema' + +export const CONNECTION_STRING = `postgres://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.POSTGRES_DB}` + +const queryClient = postgres(CONNECTION_STRING, { max: 1 }) + +export const db = drizzle(queryClient, { schema }) diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..e1b0929 --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,17 @@ +import { drizzle } from 'drizzle-orm/postgres-js' +import { migrate } from 'drizzle-orm/postgres-js/migrator' +import postgres from 'postgres' + +import { CONNECTION_STRING } from '.' + +async function main() { + const client = postgres(CONNECTION_STRING, { max: 1 }) + await migrate(drizzle(client), { migrationsFolder: './src/db/migrations' }) + + await client.end() +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/db/migrations/0000_round_scream.sql b/src/db/migrations/0000_round_scream.sql new file mode 100644 index 0000000..8493667 --- /dev/null +++ b/src/db/migrations/0000_round_scream.sql @@ -0,0 +1,41 @@ +DO $$ BEGIN + CREATE TYPE "public"."campus" AS ENUM('Ingolstadt', 'Neuburg'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."weekday" AS ENUM('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "app_announcements" ( + "id" serial PRIMARY KEY NOT NULL, + "title_de" text NOT NULL, + "title_en" text NOT NULL, + "description_de" text NOT NULL, + "description_en" text NOT NULL, + "start_date_time" timestamp with time zone NOT NULL, + "end_date_time" timestamp with time zone NOT NULL, + "priority" integer NOT NULL, + "url" text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "university_sports" ( + "id" serial PRIMARY KEY NOT NULL, + "title_de" text NOT NULL, + "description_de" text, + "title_en" text NOT NULL, + "description_en" text, + "campus" "campus" NOT NULL, + "location" text NOT NULL, + "weekday" "weekday" NOT NULL, + "start_time" time NOT NULL, + "end_time" time, + "requires_registration" boolean NOT NULL, + "invitation_link" text, + "e_mail" text, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); diff --git a/src/db/migrations/0001_free_midnight.sql b/src/db/migrations/0001_free_midnight.sql new file mode 100644 index 0000000..beb4661 --- /dev/null +++ b/src/db/migrations/0001_free_midnight.sql @@ -0,0 +1,2 @@ +ALTER TABLE "app_announcements" ADD COLUMN "created_at" timestamp with time zone NOT NULL;--> statement-breakpoint +ALTER TABLE "app_announcements" ADD COLUMN "updated_at" timestamp with time zone NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/0000_snapshot.json b/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..fbd2330 --- /dev/null +++ b/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,201 @@ +{ + "id": "d2bb0397-3617-4341-90a8-33baf6ed1f1e", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_announcements": { + "name": "app_announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title_de": { + "name": "title_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_de": { + "name": "description_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date_time": { + "name": "start_date_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_date_time": { + "name": "end_date_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.university_sports": { + "name": "university_sports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title_de": { + "name": "title_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_de": { + "name": "description_de", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "campus": { + "name": "campus", + "type": "campus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weekday": { + "name": "weekday", + "type": "weekday", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "time", + "primaryKey": false, + "notNull": false + }, + "requires_registration": { + "name": "requires_registration", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "invitation_link": { + "name": "invitation_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "e_mail": { + "name": "e_mail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.campus": { + "name": "campus", + "schema": "public", + "values": ["Ingolstadt", "Neuburg"] + }, + "public.weekday": { + "name": "weekday", + "schema": "public", + "values": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..81d52fe --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,213 @@ +{ + "id": "d5377968-5e6d-4e8c-b6d7-f08ffa34b769", + "prevId": "d2bb0397-3617-4341-90a8-33baf6ed1f1e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.university_sports": { + "name": "university_sports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title_de": { + "name": "title_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_de": { + "name": "description_de", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "campus": { + "name": "campus", + "type": "campus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weekday": { + "name": "weekday", + "type": "weekday", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "time", + "primaryKey": false, + "notNull": false + }, + "requires_registration": { + "name": "requires_registration", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "invitation_link": { + "name": "invitation_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "e_mail": { + "name": "e_mail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.app_announcements": { + "name": "app_announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title_de": { + "name": "title_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_de": { + "name": "description_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date_time": { + "name": "start_date_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_date_time": { + "name": "end_date_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.campus": { + "name": "campus", + "schema": "public", + "values": ["Ingolstadt", "Neuburg"] + }, + "public.weekday": { + "name": "weekday", + "schema": "public", + "values": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..aef8d15 --- /dev/null +++ b/src/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1727197753290, + "tag": "0000_round_scream", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1727650069941, + "tag": "0001_free_midnight", + "breakpoints": true + } + ] +} diff --git a/src/db/schema/appAnnouncements.ts b/src/db/schema/appAnnouncements.ts new file mode 100644 index 0000000..7d9d34e --- /dev/null +++ b/src/db/schema/appAnnouncements.ts @@ -0,0 +1,38 @@ +import { + boolean, + pgEnum, + pgTable, + serial, + text, + time, + timestamp, +} from 'drizzle-orm/pg-core' + +export const campusEnum = pgEnum('campus', ['Ingolstadt', 'Neuburg']) +export const weekdayEnum = pgEnum('weekday', [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', +]) + +export const universitySports = pgTable('university_sports', { + id: serial('id').primaryKey(), + title_de: text('title_de').notNull(), + description_de: text('description_de'), + title_en: text('title_en').notNull(), + description_en: text('description_en'), + campus: campusEnum('campus').notNull(), + location: text('location').notNull(), + weekday: weekdayEnum('weekday').notNull(), + start_time: time('start_time').notNull(), + end_time: time('end_time'), + requires_registration: boolean('requires_registration').notNull(), + invitation_link: text('invitation_link'), + e_mail: text('e_mail'), + created_at: timestamp('created_at', { withTimezone: true }).notNull(), + updated_at: timestamp('updated_at', { withTimezone: true }).notNull(), +}) diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts new file mode 100644 index 0000000..134bf2c --- /dev/null +++ b/src/db/schema/index.ts @@ -0,0 +1,4 @@ +import * as appAnnouncements from '@/db/schema/appAnnouncements' +import * as universitySports from '@/db/schema/universitySports' + +export default { ...appAnnouncements, ...universitySports } diff --git a/src/db/schema/universitySports.ts b/src/db/schema/universitySports.ts new file mode 100644 index 0000000..aab8389 --- /dev/null +++ b/src/db/schema/universitySports.ts @@ -0,0 +1,17 @@ +import { integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core' + +export const appAnnouncements = pgTable('app_announcements', { + id: serial('id').primaryKey(), + title_de: text('title_de').notNull(), + title_en: text('title_en').notNull(), + description_de: text('description_de').notNull(), + description_en: text('description_en').notNull(), + start_date_time: timestamp('start_date_time', { + withTimezone: true, + }).notNull(), + end_date_time: timestamp('end_date_time', { withTimezone: true }).notNull(), + priority: integer('priority').notNull(), + url: text('url'), + created_at: timestamp('created_at', { withTimezone: true }).notNull(), + updated_at: timestamp('updated_at', { withTimezone: true }).notNull(), +}) diff --git a/src/mutations/app-announcements/delete.ts b/src/mutations/app-announcements/delete.ts new file mode 100644 index 0000000..6ed8d6f --- /dev/null +++ b/src/mutations/app-announcements/delete.ts @@ -0,0 +1,34 @@ +import { db } from '@/db' +import { appAnnouncements } from '@/db/schema/universitySports' +import { announcementRole } from '@/utils/auth-utils' +import { eq } from 'drizzle-orm' +import { GraphQLError } from 'graphql' + +export async function deleteAppAnnouncement( + _: unknown, + { + id, + }: { + id: number + }, + contextValue: { jwtPayload?: { groups: string[] } } +): Promise { + if (!contextValue.jwtPayload) { + throw new GraphQLError('Not authorized: Missing JWT payload') + } + + if (!contextValue.jwtPayload.groups.includes(announcementRole)) { + throw new GraphQLError('Not authorized: Insufficient permissions') + } + try { + const rowsDeleted = await db + .delete(appAnnouncements) + .where(eq(appAnnouncements.id, id)) + + return rowsDeleted.length > 0 + } catch (error) { + throw new GraphQLError( + `Failed to delete the app announcement with id ${id}: ${error}` + ) + } +} diff --git a/src/mutations/app-announcements/upsert.ts b/src/mutations/app-announcements/upsert.ts new file mode 100644 index 0000000..4a18856 --- /dev/null +++ b/src/mutations/app-announcements/upsert.ts @@ -0,0 +1,78 @@ +import { db } from '@/db' +import { appAnnouncements } from '@/db/schema/universitySports' +import { announcementRole } from '@/utils/auth-utils' +import { eq } from 'drizzle-orm' +import { GraphQLError } from 'graphql' + +export async function upsertAppAnnouncement( + _: unknown, + { + id, + input, + }: { + id: number | undefined + input: AnnouncementInput + }, + contextValue: { jwtPayload?: { groups: string[] } } +): Promise<{ + id: number +}> { + if (!contextValue.jwtPayload) { + throw new GraphQLError('Not authorized: Missing JWT payload') + } + + if (!contextValue.jwtPayload.groups.includes(announcementRole)) { + throw new GraphQLError('Not authorized: Insufficient permissions') + } + + const { title, description, startDateTime, endDateTime, priority, url } = + input + + let announcement + + if (id != null) { + // Perform update + ;[announcement] = await db + .update(appAnnouncements) + .set({ + title_de: title.de, + title_en: title.en, + description_de: description.de, + description_en: description.en, + start_date_time: startDateTime, + end_date_time: endDateTime, + priority, + url, + updated_at: new Date(), + }) + .where(eq(appAnnouncements.id, id)) + + .returning({ + id: appAnnouncements.id, + }) + } else { + // Perform insert + ;[announcement] = await db + .insert(appAnnouncements) + .values({ + title_de: title.de, + title_en: title.en, + description_de: description.de, + description_en: description.en, + start_date_time: startDateTime, + end_date_time: endDateTime, + priority, + url, + created_at: new Date(), + updated_at: new Date(), + }) + + .returning({ + id: appAnnouncements.id, + }) + } + + return { + id: announcement.id, + } +} diff --git a/src/mutations/university-sports/delete.ts b/src/mutations/university-sports/delete.ts new file mode 100644 index 0000000..238ce86 --- /dev/null +++ b/src/mutations/university-sports/delete.ts @@ -0,0 +1,34 @@ +import { db } from '@/db' +import { universitySports } from '@/db/schema/appAnnouncements' +import { sportRole } from '@/utils/auth-utils' +import { eq } from 'drizzle-orm' +import { GraphQLError } from 'graphql' + +export async function deleteUniversitySport( + _: unknown, + { + id, + }: { + id: number + }, + contextValue: { jwtPayload?: { groups: string[] } } +): Promise { + if (!contextValue.jwtPayload) { + throw new GraphQLError('Not authorized: Missing JWT payload') + } + + if (!contextValue.jwtPayload.groups.includes(sportRole)) { + throw new GraphQLError('Not authorized: Insufficient permissions') + } + + try { + const rowsDeleted = await db + .delete(universitySports) + .where(eq(universitySports.id, id)) + return rowsDeleted.length > 0 + } catch (error) { + throw new GraphQLError( + `Failed to delete the university sport with id ${id}: ${error}` + ) + } +} diff --git a/src/mutations/university-sports/upsert.ts b/src/mutations/university-sports/upsert.ts new file mode 100644 index 0000000..f269a7e --- /dev/null +++ b/src/mutations/university-sports/upsert.ts @@ -0,0 +1,90 @@ +import { db } from '@/db' +import { universitySports } from '@/db/schema/appAnnouncements' +import { sportRole } from '@/utils/auth-utils' +import { eq } from 'drizzle-orm' +import { GraphQLError } from 'graphql' +import type { JwtPayload } from 'jsonwebtoken' + +export async function upsertUniversitySport( + _: unknown, + { + id, + input, + }: { + id: number | undefined + input: UniversitySportInput + }, + contextValue: { jwtPayload?: JwtPayload } +): Promise<{ id: number }> { + const { + title, + description, + campus, + location, + weekday, + startTime, + endTime, + requiresRegistration, + invitationLink, + eMail, + } = input + + if (!contextValue.jwtPayload) { + throw new GraphQLError('Not authorized: Missing JWT payload') + } + + if (!contextValue.jwtPayload.groups.includes(sportRole)) { + throw new GraphQLError('Not authorized: Insufficient permissions') + } + + let event + + if (id != null) { + ;[event] = await db + .update(universitySports) + .set({ + title_de: title.de, + title_en: title.en, + description_de: description?.de ?? null, + description_en: description?.en ?? null, + campus, + location, + weekday, + start_time: startTime, + end_time: endTime, + requires_registration: requiresRegistration, + invitation_link: invitationLink ?? null, + e_mail: eMail ?? null, + updated_at: new Date(), + }) + .where(eq(universitySports.id, id)) + .returning({ + id: universitySports.id, + }) + } else { + ;[event] = await db + .insert(universitySports) + .values({ + title_de: title.de, + title_en: title.en, + description_de: description?.de ?? null, + description_en: description?.en ?? null, + campus, + location, + weekday, + start_time: startTime, + end_time: endTime, + requires_registration: requiresRegistration, + invitation_link: invitationLink ?? null, + e_mail: eMail ?? null, + created_at: new Date(), + updated_at: new Date(), + }) + .returning({ + id: universitySports.id, + }) + } + return { + id: event.id, + } +} diff --git a/src/queries/appAnnouncements.ts b/src/queries/appAnnouncements.ts new file mode 100644 index 0000000..34f51ef --- /dev/null +++ b/src/queries/appAnnouncements.ts @@ -0,0 +1,24 @@ +import { db } from '@/db' +import { appAnnouncements } from '@/db/schema/universitySports' + +export async function appAnnouncementsQuery(): Promise { + const data = await db.select().from(appAnnouncements) + + return data.map((announcement) => ({ + id: announcement.id, + title: { + de: announcement.title_de, + en: announcement.title_en, + }, + description: { + de: announcement.description_de, + en: announcement.description_en, + }, + startDateTime: announcement.start_date_time, + endDateTime: announcement.end_date_time, + priority: announcement.priority, + url: announcement.url, + createdAt: announcement.created_at, + updatedAt: announcement.updated_at, + })) +} diff --git a/src/resolvers/bus.ts b/src/queries/bus.ts similarity index 72% rename from src/resolvers/bus.ts rename to src/queries/bus.ts index 1f61bfd..603051b 100644 --- a/src/resolvers/bus.ts +++ b/src/queries/bus.ts @@ -1,10 +1,12 @@ +import { cache } from '@/index' import getBus from '@/scraping/bus' -import { cache } from '../..' - const CACHE_TTL = 60 // 1 minute -export async function bus(_: any, args: { station: string }): Promise { +export async function bus( + _: unknown, + args: { station: string } +): Promise { let busData: Bus[] | undefined = await cache.get(`bus__${args.station}`) if (busData === undefined || busData === null) { diff --git a/src/resolvers/charging.ts b/src/queries/charging.ts similarity index 91% rename from src/resolvers/charging.ts rename to src/queries/charging.ts index 983c442..359efa3 100644 --- a/src/resolvers/charging.ts +++ b/src/queries/charging.ts @@ -1,7 +1,6 @@ +import { cache } from '@/index' import { getCharging } from '@/scraping/charging' -import { cache } from '../..' - export const charging = async (): Promise => { const data = cache.get('chargingStations') if (data == null) { diff --git a/src/resolvers/cl-events.ts b/src/queries/cl-events.ts similarity index 93% rename from src/resolvers/cl-events.ts rename to src/queries/cl-events.ts index 90e59d7..827c247 100644 --- a/src/resolvers/cl-events.ts +++ b/src/queries/cl-events.ts @@ -1,8 +1,7 @@ +import { cache } from '@/index' import getClEvents from '@/scraping/cl-event' import type { ClEvent } from '@/types/clEvents' -import { cache } from '../..' - const CACHE_TTL = 60 * 60 * 24 // 24 hours export async function clEvents(): Promise { diff --git a/src/resolvers/food.ts b/src/queries/food.ts similarity index 96% rename from src/resolvers/food.ts rename to src/queries/food.ts index a95de61..c335c89 100644 --- a/src/resolvers/food.ts +++ b/src/queries/food.ts @@ -1,16 +1,15 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { cache } from '@/index' import { getCanisiusPlan } from '@/scraping/canisius' import { getMensaPlan } from '@/scraping/mensa' import { getReimannsPlan } from '@/scraping/reimanns' import type { MealData, ReturnData } from '@/types/food' import { GraphQLError } from 'graphql' -import { cache } from '../..' - const CACHE_TTL = 60 * 30 // 30 minutes export async function food( - _: any, + _: unknown, args: { locations: string[] } ): Promise { const validLocations = [ diff --git a/src/resolvers/parking.ts b/src/queries/parking.ts similarity index 90% rename from src/resolvers/parking.ts rename to src/queries/parking.ts index c93cb66..6e2de35 100644 --- a/src/resolvers/parking.ts +++ b/src/queries/parking.ts @@ -1,7 +1,6 @@ +import { cache } from '@/index' import getParking from '@/scraping/parking' -import { cache } from '../..' - export const parking = async (): Promise => { const data = cache.get('parking') if (data == null) { diff --git a/src/queries/sports.ts b/src/queries/sports.ts new file mode 100644 index 0000000..19ca5a6 --- /dev/null +++ b/src/queries/sports.ts @@ -0,0 +1,28 @@ +import { db } from '@/db' +import { universitySports } from '@/db/schema/appAnnouncements' + +export async function sports(): Promise { + const data = await db.select().from(universitySports) + + return data.map((sport) => ({ + id: sport.id, + title: { + de: sport.title_de, + en: sport.title_en, + }, + description: { + de: sport.description_de, + en: sport.description_en, + }, + campus: sport.campus, + location: sport.location, + weekday: sport.weekday, + startTime: sport.start_time, + endTime: sport.end_time, + requiresRegistration: sport.requires_registration, + invitationLink: sport.invitation_link, + eMail: sport.e_mail, + createdAt: sport.created_at, + updatedAt: sport.updated_at, + })) +} diff --git a/src/resolvers/train.ts b/src/queries/train.ts similarity index 90% rename from src/resolvers/train.ts rename to src/queries/train.ts index 3b63467..8d857c8 100644 --- a/src/resolvers/train.ts +++ b/src/queries/train.ts @@ -1,11 +1,10 @@ +import { cache } from '@/index' import getTrain from '@/scraping/train' -import { cache } from '../..' - const CACHE_TTL = 60 // 1 minute export async function train( - _: any, + _: unknown, args: { station: string } ): Promise { let trainData: Train[] | undefined = await cache.get( diff --git a/src/resolvers.ts b/src/resolvers.ts new file mode 100644 index 0000000..de2bb6c --- /dev/null +++ b/src/resolvers.ts @@ -0,0 +1,42 @@ +import { deleteUniversitySport } from '@/mutations/university-sports/delete' +import { upsertUniversitySport } from '@/mutations/university-sports/upsert' +import { bus } from '@/queries/bus' +import { charging } from '@/queries/charging' +import { clEvents } from '@/queries/cl-events' +import { food } from '@/queries/food' +import { parking } from '@/queries/parking' +import { sports } from '@/queries/sports' +import { train } from '@/queries/train' +import { + DateTimeResolver, + EmailAddressResolver, + LocalEndTimeResolver, +} from 'graphql-scalars' + +import { deleteAppAnnouncement } from './mutations/app-announcements/delete' +import { upsertAppAnnouncement } from './mutations/app-announcements/upsert' +import { appAnnouncementsQuery } from './queries/appAnnouncements' + +export const resolvers = { + Query: { + charging, + parking, + food, + clEvents, + bus, + train, + appAnnouncements: appAnnouncementsQuery, + announcements: appAnnouncementsQuery, + universitySports: sports, + }, + Mutation: { + deleteUniversitySport, + upsertUniversitySport, + deleteAppAnnouncement, + upsertAppAnnouncement, + }, + + LocalTime: LocalEndTimeResolver, + DateTime: DateTimeResolver, + EmailAddress: EmailAddressResolver, +} diff --git a/src/resolvers/announcements.ts b/src/resolvers/announcements.ts deleted file mode 100644 index 1e2aba2..0000000 --- a/src/resolvers/announcements.ts +++ /dev/null @@ -1,52 +0,0 @@ -import demoData from '@/data/demo-data.json' -import crypto from 'crypto' -import fs from 'fs/promises' - -const dataStore = `${Bun.env.STORE}/announcements.json` -const isDev = Bun.env.NODE_ENV !== 'production' - -/** - * Announcement data. - * In development mode, this will return demo data. - */ -export async function announcements(): Promise { - if (isDev) { - return demoData.announcements.map((announcement) => { - const id = crypto - .createHash('md5') - .update(announcement.title.en + announcement.startDateTime) - .digest('hex') - const startDateTime = new Date(announcement.startDateTime) - const endDateTime = new Date(announcement.endDateTime) - return { ...announcement, id, startDateTime, endDateTime } - }) - } - - let fileHandle - try { - fileHandle = await fs.open(dataStore, 'a+') - const data = await fileHandle.readFile() - const fileContent = data.toString() - - if (fileContent.length === 0) { - return [] - } - - return JSON.parse(fileContent).map((announcement: Announcement) => ({ - ...announcement, - id: crypto - .createHash('md5') - .update( - announcement.title.en + - announcement.startDateTime.toString() - ) - .digest('hex'), - startDateTime: new Date(announcement.startDateTime), - endDateTime: new Date(announcement.endDateTime), - })) - } finally { - if (fileHandle != null) { - await fileHandle.close() - } - } -} diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts deleted file mode 100644 index 7e7bc2a..0000000 --- a/src/resolvers/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { announcements } from './announcements' -import { bus } from './bus' -import { charging } from './charging' -import { clEvents } from './cl-events' -import { food } from './food' -import { parking } from './parking' -import { train } from './train' - -export const resolvers = { - Query: { - charging, - parking, - food, - clEvents, - bus, - train, - announcements, - }, -} diff --git a/src/schema.gql b/src/schema.gql index 9e87199..0fb7960 100644 --- a/src/schema.gql +++ b/src/schema.gql @@ -424,11 +424,11 @@ type Announcement { """ Start date and time when the announcement is displayed """ - startDateTime: String! + startDateTime: DateTime! """ End date and time when the announcement is displayed """ - endDateTime: String! + endDateTime: DateTime! """ Priority of the announcement, higher are more important """ @@ -437,6 +437,14 @@ type Announcement { URL to the announcement """ url: String + """ + Creation date of the announcement + """ + createdAt: DateTime! + """ + Last update date of the announcement + """ + updatedAt: DateTime! } """ @@ -485,4 +493,203 @@ type Query { Get the current announcements """ announcements: [Announcement!]! + @deprecated(reason: "Use appAnnouncements query instead") + """ + Get the current in app announcements + """ + appAnnouncements: [Announcement!]! + """ + Get the university sports events + """ + universitySports: [UniversitySports!] # This returns a list of all sports +} + +""" +Enum representing the different locations of THI. +""" +enum CampusType { + Ingolstadt + Neuburg +} + +""" +Enum representing the different weekdays. +""" +enum WeekdayType { + Monday + Tuesday + Wednesday + Thursday + Friday + Saturday + Sunday +} + +""" +University sports event data +""" +type UniversitySports { + """ + Unique identifier of the sports event + """ + id: ID! + """ + Title of the sports event in different languages + """ + title: MultiLanguageString! + """ + Description of the sports event in different languages + """ + description: MultiLanguageString + """ + Campus where the sports event belongs to. This is not the location of the event itself. + """ + campus: CampusType! + """ + Location of the sports event + """ + location: String! + """ + Weekday of the sports event + """ + weekday: WeekdayType! + """ + Start time of the sports event + """ + startTime: LocalTime! + """ + End time of the sports event + """ + endTime: LocalTime + """ + True if the sports event requires registration + """ + requiresRegistration: Boolean! + """ + Invitation link for the sports event, e.g. a WhatsApp group + """ + invitationLink: String + """ + E-Mail address for registration or contact + """ + eMail: EmailAddress + """ + Creation date of the sports event + """ + createdAt: DateTime! + """ + Last update date of the sports event + """ + updatedAt: DateTime! +} + +input MultiLanguageStringInput { + en: String! + de: String! } + +""" +Input type for the university sports event +""" +input UniversitySportsInput { + """ + Title of the sports event in different languages + """ + title: MultiLanguageStringInput! + """ + Description of the sports event in different languages + """ + description: MultiLanguageStringInput + """ + Campus where the sports event belongs to. This is not the location of the event itself. + """ + campus: CampusType! + """ + Location of the sports event + """ + location: String! + """ + Weekday of the sports event + """ + weekday: WeekdayType! + """ + Start time of the sports event as Unix timestamp + """ + startTime: LocalTime! + """ + End time of the sports event as Unix timestamp + """ + endTime: LocalTime + """ + True if the sports event requires registration + """ + requiresRegistration: Boolean! + """ + Invitation link for the sports event, e.g. a WhatsApp group + """ + invitationLink: String + """ + E-Mail address for registration or contact + """ + eMail: EmailAddress +} + +""" +Input type for the announcement +""" +input AnnouncementInput { + """ + Title of the announcement in different languages + """ + title: MultiLanguageStringInput! + """ + Description of the announcement in different languages + """ + description: MultiLanguageStringInput! + """ + Start date and time when the announcement is displayed + """ + startDateTime: DateTime! + """ + End date and time when the announcement is displayed + """ + endDateTime: DateTime! + """ + Priority of the announcement, higher are more important + """ + priority: Int! + """ + URL to the announcement + """ + url: String +} + +""" +Mutation type to update data +""" +type Mutation { + """ + Create or update a university sports event. If an ID is provided, the event is updated, otherwise a new event is created. + """ + upsertUniversitySport(id: ID, input: UniversitySportsInput!): UpsertResponse + """ + Delete a university sports event by ID + """ + deleteUniversitySport(id: ID!): Boolean + """ + Create or update an announcement. If an ID is provided, the announcement is updated, otherwise a new announcement is created. + """ + upsertAppAnnouncement(id: ID, input: AnnouncementInput!): UpsertResponse + """ + Delete an announcement by ID + """ + deleteAppAnnouncement(id: ID!): Boolean +} + +type UpsertResponse { + id: ID +} + +scalar DateTime +scalar LocalTime +scalar EmailAddress diff --git a/src/scraping/bus.ts b/src/scraping/bus.ts index 67c4e76..e23e7b1 100644 --- a/src/scraping/bus.ts +++ b/src/scraping/bus.ts @@ -57,7 +57,11 @@ export default async function getBus(station: string): Promise { } } return await departures() - } catch (e: any) { - throw new GraphQLError('Failed to fetch data: ' + e.message) + } catch (e) { + if (e instanceof Error) { + throw new GraphQLError('Failed to fetch data: ' + e.message) + } else { + throw new GraphQLError('Failed to fetch data: Unknown error') + } } } diff --git a/src/scraping/charging.ts b/src/scraping/charging.ts index d67c0cd..f25e447 100644 --- a/src/scraping/charging.ts +++ b/src/scraping/charging.ts @@ -18,6 +18,7 @@ export const getCharging = async (): Promise => { city: string coordinates: { latitude: number; longitude: number } evses: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any filter: (arg0: (x: any) => boolean) => { (): number new (): number diff --git a/src/scraping/cl-event.ts b/src/scraping/cl-event.ts index 2b2a283..e02dfa6 100644 --- a/src/scraping/cl-event.ts +++ b/src/scraping/cl-event.ts @@ -246,7 +246,9 @@ export async function getAllEventDetails( remoteEvents.push({ id: crypto.createHash('sha256').update(url).digest('hex'), - organizer: details.Verein.trim().replace(/( \.)$/g, ''), + organizer: details.Verein.trim() + .replace(/( \.)$/g, '') + .replace(/e\. V\./g, 'e.V.'), title: details.Event, begin: details.Start.length > 0 @@ -300,8 +302,16 @@ export default async function getClEvents(): Promise { } else { throw new GraphQLError('MOODLE_CREDENTIALS_NOT_CONFIGURED') } - } catch (e: any) { - console.error(e) - throw new GraphQLError('Unexpected error' + e.toString()) + } catch (e: unknown) { + if (e instanceof GraphQLError) { + console.error(e) + throw e + } else if (e instanceof Error) { + console.error(e) + throw new GraphQLError('Unexpected error: ' + e.message) + } else { + console.error('Unexpected error:', e) + throw new GraphQLError('Unexpected error') + } } } diff --git a/src/scraping/mensa.ts b/src/scraping/mensa.ts index 649a5ea..42bb39e 100644 --- a/src/scraping/mensa.ts +++ b/src/scraping/mensa.ts @@ -1,4 +1,9 @@ -import type { ExtendedMealData, MealData, XMLMensa } from '@/types/food' +import type { + ExtendedMealData, + MealData, + XMLMensa, + XMLSourceData, +} from '@/types/food' import xmljs from 'xml-js' import { formatISODate } from '../utils/date-utils' @@ -17,7 +22,7 @@ import { translateMeals } from '../utils/translation-utils' * @returns {ExtendedMealData[]} The parsed meal plan */ function parseDataFromXml(xml: string, location: string): ExtendedMealData[] { - const sourceData = xmljs.xml2js(xml, { compact: true }) as any + const sourceData = xmljs.xml2js(xml, { compact: true }) as XMLSourceData let sourceDays = sourceData.speiseplan.tag as XMLMensa[] if (sourceDays == null) { diff --git a/src/scraping/train.ts b/src/scraping/train.ts index 959912d..28163cf 100644 --- a/src/scraping/train.ts +++ b/src/scraping/train.ts @@ -56,7 +56,7 @@ export default async function getTrain(station: string): Promise { for (const key in paramObj) { params.append( key, - (paramObj as unknown as Record)[key] + (paramObj as unknown as Record)[key] ) } @@ -98,7 +98,11 @@ export default async function getTrain(station: string): Promise { return departures.get() } return await getTrainDepatures() - } catch (e: any) { - throw new GraphQLError('Failed to fetch data: ' + e.message) + } catch (e: unknown) { + if (e instanceof Error) { + throw new GraphQLError('Failed to fetch data: ' + e.message) + } else { + throw new GraphQLError('Failed to fetch data: Unknown error') + } } } diff --git a/src/types/announcement.d.ts b/src/types/announcement.d.ts index b9ce9cb..d83e37f 100644 --- a/src/types/announcement.d.ts +++ b/src/types/announcement.d.ts @@ -1,5 +1,5 @@ interface Announcement { - id: string + id: number title: { de: string en: string @@ -12,4 +12,21 @@ interface Announcement { endDateTime: Date | string priority: number url: string | null + createdAt: Date + updatedAt: Date +} + +interface AnnouncementInput { + title: { + de: string + en: string + } + description: { + de: string + en: string + } + startDateTime: Date + endDateTime: Date + priority: number + url: string | null } diff --git a/src/types/food.d.ts b/src/types/food.d.ts index ebb7102..43f83ea 100644 --- a/src/types/food.d.ts +++ b/src/types/food.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ export interface PreFoodData { timestamp: string meals: PreMeal[] @@ -115,7 +116,13 @@ export interface XMLMensa { _attributes: Attributes item: Item[] } +interface XMLSpeiseplan { + tag: XMLMensa[] +} +interface XMLSourceData { + speiseplan: XMLSpeiseplan +} interface Attributes { timestamp: string } diff --git a/src/types/sports.d.ts b/src/types/sports.d.ts new file mode 100644 index 0000000..1dfb00b --- /dev/null +++ b/src/types/sports.d.ts @@ -0,0 +1,51 @@ +type CampusType = 'Ingolstadt' | 'Neuburg' + +type WeekdayType = + | 'Monday' + | 'Tuesday' + | 'Wednesday' + | 'Thursday' + | 'Friday' + | 'Saturday' + | 'Sunday' + +interface UniversitySports { + id: number + title: { + de: string + en: string + } + description: { + de: string | null + en: string | null + } + campus: CampusType + location: string + weekday: WeekdayType + startTime: string + endTime: string | null + requiresRegistration: boolean + invitationLink: string | null + eMail: string | null + createdAt: Date + updatedAt: Date +} + +interface UniversitySportInput { + title: { + de: string + en: string + } + description: { + de: string + en: string + } + campus: CampusType + location: string + weekday: WeekdayType + startTime: string + endTime?: string + requiresRegistration: boolean + invitationLink?: string + eMail?: string +} diff --git a/src/utils/auth-utils.ts b/src/utils/auth-utils.ts new file mode 100644 index 0000000..ec4d6f0 --- /dev/null +++ b/src/utils/auth-utils.ts @@ -0,0 +1,26 @@ +import axios from 'axios' +import jwt, { type JwtPayload } from 'jsonwebtoken' +import jwkToPem from 'jwk-to-pem' + +export const sportRole = 'Neuland Next Hochschulsport' +export const announcementRole = 'Neuland Next Announcements' +const jwkUrl = + 'https://sso.informatik.sexy/application/o/neulandnextpanel/jwks/' + +async function getPublicKey(): Promise { + const response = await axios.get(jwkUrl) + const jwk = response.data.keys[0] + return jwkToPem(jwk) +} + +export async function getUserFromToken(bearer: string): Promise { + const publicKey = await getPublicKey() + try { + const token = bearer.split(' ')[1] + const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) + return payload as JwtPayload + } catch (error) { + console.error('Failed to verify token:', error) + throw new Error('Failed to verify token') + } +} diff --git a/src/utils/date-utils.ts b/src/utils/date-utils.ts index 6d8d39c..2b792d4 100644 --- a/src/utils/date-utils.ts +++ b/src/utils/date-utils.ts @@ -1,3 +1,9 @@ +interface DateParts { + year?: string + month?: string + day?: string +} + /** * Formats a date like "2020-10-01" * @param {Date} date @@ -14,7 +20,7 @@ export function formatISODate(date: Date | undefined): string { day: '2-digit', }) const parts = formatter.formatToParts(date) - const { year, month, day } = parts.reduce( + const { year, month, day } = parts.reduce( (allParts, part) => ({ ...allParts, [part.type]: part.value }), {} ) @@ -55,7 +61,7 @@ export function getWeek(date: Date): [Date, Date] { export function getDays(begin: Date, end: Date): Date[] { const days = [] const date = new Date(begin) - // eslint-disable-next-line no-unmodified-loop-condition + while (date < end) { days.push(new Date(date)) date.setDate(date.getDate() + 1) @@ -74,3 +80,12 @@ export function addWeek(date: Date, delta: number): Date { date.setDate(date.getDate() + delta * 7) return date } + +/** + * Converts a iso date string to a postgres date string + * @param {number} isoDate + * @returns {string} + */ +export function isoToPostgres(isoDate: number): string { + return new Date(isoDate).toISOString().replace('Z', '').replace('T', ' ') +} diff --git a/src/utils/food-utils.ts b/src/utils/food-utils.ts index bbe0d14..bcd3fe3 100644 --- a/src/utils/food-utils.ts +++ b/src/utils/food-utils.ts @@ -279,10 +279,10 @@ export function parseXmlFloat(str: { _text?: string }): number { /** * Checks whether a value is empty. - * @param any value + * @param {*} value * @returns {Boolean} */ -export function isEmpty(value: any): boolean { +export function isEmpty(value: unknown): boolean { return ( value == null || (typeof value === 'string' && value.trim().length === 0) diff --git a/src/utils/translation-utils.ts b/src/utils/translation-utils.ts index b979abe..b2806f6 100644 --- a/src/utils/translation-utils.ts +++ b/src/utils/translation-utils.ts @@ -1,4 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { cache } from '@/index' import type { ExtendedMealData, MealData, @@ -7,14 +8,12 @@ import type { } from '@/types/food' import translate, { type DeeplLanguages } from 'deepl' -import { cache } from '../..' - const deeplEndpoint = 'https://api-free.deepl.com/v2/translate' -const deeplApiKey = Bun.env.DEEPL_API_KEY ?? '' +const deeplApiKey = Bun.env.DEEPL_API_KEY || '' const enableDevTranslations = - Bun.env.ENABLE_DEV_TRANSLATIONS === 'true' ?? false + Bun.env.ENABLE_DEV_TRANSLATIONS === 'true' || false const disableFallbackWarning = - Bun.env.DISABLE_FALLBACK_WARNING === 'true' ?? false + Bun.env.DISABLE_FALLBACK_WARNING === 'true' || false const translationsCacheTTL = 60 * 60 * 24 * 14 // 14 days const isDev = Bun.env.NODE_ENV !== 'production' diff --git a/tsconfig.json b/tsconfig.json index 5187679..b54538b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "noFallthroughCasesInSwitch": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@/index": ["./index.ts"] } } }