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 de3c08c..839a7a4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yml b/docker-compose.yml index 26238bb..a6895b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: app: build: diff --git a/documentation/config.yml b/documentation/config.yml index 8492602..514efd0 100644 --- a/documentation/config.yml +++ b/documentation/config.yml @@ -1,7 +1,5 @@ introspection: - schemaFile: - - ./src/schema.gql - + url: http://localhost:4321/graphql servers: - url: https://api.neuland.app/graphql description: Neuland API Server @@ -19,6 +17,8 @@ info: license: name: AGPL-3.0 url: 'https://www.gnu.org/licenses/agpl-3.0.html' +extensions: + scalarGraphql: true spectaql: faviconFile: ./documentation/icon.svg theme: spectaql diff --git a/documentation/generated/index.html b/documentation/generated/index.html index 4dd6477..7e6c459 100644 --- a/documentation/generated/index.html +++ b/documentation/generated/index.html @@ -36,15 +36,27 @@ + + -
  • @@ -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"] } } }