├── .husky ├── pre-commit └── commit-msg ├── .commitlintrc.js ├── assets ├── schema.png ├── schema2.png ├── schema3.png ├── solvro_dark.png └── solvro_light.png ├── ellipsis.yaml ├── eslint.config.js ├── .github ├── dependabot.yml └── workflows │ ├── db.yml │ └── ci.yml ├── tsconfig.json ├── start.sh ├── .dockerignore ├── .env.example ├── .editorconfig ├── .gitignore ├── commands ├── scrape_menu.ts ├── scrape_users.ts └── docs_generate.ts ├── database └── migrations │ ├── 1730118275276_create_website_hashes_table.ts │ ├── 1760712396000_add_updated_at_to_subscriptions_table.ts │ ├── 1730994081410_create_sks_users_table.ts │ ├── 1750414002534_create_devices_table.ts │ ├── 1750414928067_create_subscriptions_table.ts │ ├── 1730118314055_create_meals_table.ts │ └── 1731367612817_create_hashes_meals_table.ts ├── config ├── cors.ts ├── hash.ts ├── database.ts ├── logger.ts ├── app.ts ├── bodyparser.ts └── swagger.ts ├── example.firebase_service_account_key.json ├── app ├── models │ ├── sks_user.ts │ ├── website_hash.ts │ ├── hashes_meal.ts │ ├── meal.ts │ └── device.ts ├── middleware │ ├── force_json_response_middleware.ts │ └── container_bindings_middleware.ts ├── controllers │ ├── info_controller.ts │ ├── registration_tokens_controller.ts │ ├── subscriptions_controller.ts │ ├── sks_users_controller.ts │ └── meals_controller.ts └── exceptions │ └── handler.ts ├── tests ├── unit │ └── menuScrapper │ │ └── scrape_menu.spec.ts ├── fixtures │ ├── parsed_menu_expected_response.ts │ └── external_menu_response.html └── bootstrap.ts ├── Dockerfile ├── ace.js ├── start ├── scheduler.ts ├── kernel.ts ├── env.ts └── routes.ts ├── bin ├── server.ts ├── console.ts └── test.ts ├── scripts ├── users_scrapper.ts ├── favourite_meal_notifier.ts └── menu_scrapper.ts ├── adonisrc.ts ├── package.json ├── README.md ├── swagger.yml ├── swagger.json └── LICENSE /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@solvro/config/commitlint"], 3 | }; 4 | -------------------------------------------------------------------------------- /assets/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solvro/backend-topwr-sks/HEAD/assets/schema.png -------------------------------------------------------------------------------- /assets/schema2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solvro/backend-topwr-sks/HEAD/assets/schema2.png -------------------------------------------------------------------------------- /assets/schema3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solvro/backend-topwr-sks/HEAD/assets/schema3.png -------------------------------------------------------------------------------- /assets/solvro_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solvro/backend-topwr-sks/HEAD/assets/solvro_dark.png -------------------------------------------------------------------------------- /assets/solvro_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solvro/backend-topwr-sks/HEAD/assets/solvro_light.png -------------------------------------------------------------------------------- /ellipsis.yaml: -------------------------------------------------------------------------------- 1 | version: 1.3 2 | pr_review: 3 | auto_review_enabled: false 4 | auto_summarize_pr: false 5 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { solvro } from "@solvro/config/eslint"; 2 | 3 | export default solvro({ 4 | rules: { 5 | "import/no-default-export": "off", 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | allow: 8 | - dependency-name: "@solvro/config" 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.app.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build", 6 | "paths": { 7 | "#helpers/*": ["./app/helpers/*.js"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Start the server 4 | node ./bin/server.js & 5 | 6 | # Start the scheduler 7 | node ace scheduler:run & 8 | 9 | # Wait for any process to exit 10 | wait 11 | 12 | # Exit with the status of the process that exited first 13 | exit $? 14 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies and AdonisJS build 2 | node_modules 3 | build 4 | tmp 5 | 6 | # Build tools specific 7 | npm-debug.log 8 | yarn-error.log 9 | 10 | # Editors specific 11 | .fleet 12 | .idea 13 | .vscode 14 | 15 | # Platform specific 16 | .DS_Store 17 | 18 | .git 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TZ=UTC 2 | PORT=3333 3 | HOST=localhost 4 | LOG_LEVEL=info 5 | APP_KEY= 6 | NODE_ENV=development 7 | DB_HOST=127.0.0.1 8 | DB_PORT=5432 9 | DB_USER=root 10 | DB_PASSWORD=root 11 | DB_DATABASE=app 12 | MENU_URL=https://sks.pwr.edu.pl/menu/ 13 | USERS_URL=https://live.pwr.edu.pl/sks/sks-data.csv 14 | RUN_MENU_SCRAPPER=true 15 | RUN_USERS_SCRAPPER=true 16 | GOOGLE_APPLICATION_CREDENTIALS= 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = unset 13 | 14 | [**.min.js] 15 | indent_style = unset 16 | insert_final_newline = unset 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies and AdonisJS build 2 | node_modules 3 | build 4 | tmp 5 | 6 | firebase_service_account_key.json 7 | 8 | # Secrets 9 | .env 10 | .env.local 11 | .env.production.local 12 | .env.development.local 13 | 14 | # Frontend assets compiled code 15 | public/assets 16 | 17 | # Build tools specific 18 | npm-debug.log 19 | yarn-error.log 20 | 21 | # Editors specific 22 | .fleet 23 | .idea 24 | .vscode 25 | 26 | # Platform specific 27 | .DS_Store 28 | -------------------------------------------------------------------------------- /commands/scrape_menu.ts: -------------------------------------------------------------------------------- 1 | import { BaseCommand } from "@adonisjs/core/ace"; 2 | import type { CommandOptions } from "@adonisjs/core/types/ace"; 3 | 4 | import { runScrapper } from "../scripts/menu_scrapper.js"; 5 | 6 | export default class ScrapeMenu extends BaseCommand { 7 | static commandName = "scrape:menu"; 8 | static description = "Scrape sks menu data."; 9 | 10 | static options: CommandOptions = { 11 | startApp: true, 12 | }; 13 | 14 | async run() { 15 | await runScrapper(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /commands/scrape_users.ts: -------------------------------------------------------------------------------- 1 | import { BaseCommand } from "@adonisjs/core/ace"; 2 | import type { CommandOptions } from "@adonisjs/core/types/ace"; 3 | 4 | import { runScrapper } from "../scripts/users_scrapper.js"; 5 | 6 | export default class ScrapeUsers extends BaseCommand { 7 | static commandName = "scrape:users"; 8 | static description = "Scrape data about active users in SKS"; 9 | 10 | static options: CommandOptions = { 11 | startApp: true, 12 | }; 13 | 14 | async run() { 15 | await runScrapper(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /database/migrations/1730118275276_create_website_hashes_table.ts: -------------------------------------------------------------------------------- 1 | import { BaseSchema } from "@adonisjs/lucid/schema"; 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = "website_hashes"; 5 | 6 | async up() { 7 | this.schema.createTable(this.tableName, (table) => { 8 | table.text("hash").primary(); 9 | 10 | table.timestamp("created_at"); 11 | table.timestamp("updated_at"); 12 | }); 13 | } 14 | 15 | async down() { 16 | this.schema.dropTable(this.tableName); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /config/cors.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@adonisjs/cors"; 2 | 3 | /** 4 | * Configuration options to tweak the CORS policy. The following 5 | * options are documented on the official documentation website. 6 | * 7 | * https://docs.adonisjs.com/guides/security/cors 8 | */ 9 | const corsConfig = defineConfig({ 10 | enabled: true, 11 | origin: true, 12 | methods: ["GET", "HEAD", "POST", "PUT", "DELETE"], 13 | headers: true, 14 | exposeHeaders: [], 15 | credentials: true, 16 | maxAge: 90, 17 | }); 18 | 19 | export default corsConfig; 20 | -------------------------------------------------------------------------------- /database/migrations/1760712396000_add_updated_at_to_subscriptions_table.ts: -------------------------------------------------------------------------------- 1 | import { BaseSchema } from "@adonisjs/lucid/schema"; 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = "subscriptions"; 5 | 6 | async up() { 7 | this.schema.alterTable(this.tableName, (table) => { 8 | table.timestamp("updated_at").notNullable().defaultTo(this.now()); 9 | }); 10 | } 11 | 12 | async down() { 13 | this.schema.alterTable(this.tableName, (table) => { 14 | table.dropColumn("updated_at"); 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /config/hash.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, drivers } from "@adonisjs/core/hash"; 2 | 3 | const hashConfig = defineConfig({ 4 | default: "scrypt", 5 | 6 | list: { 7 | scrypt: drivers.scrypt({ 8 | cost: 16384, 9 | blockSize: 8, 10 | parallelization: 1, 11 | maxMemory: 33554432, 12 | }), 13 | }, 14 | }); 15 | 16 | export default hashConfig; 17 | 18 | /** 19 | * Inferring types for the list of hashers you have configured 20 | * in your application. 21 | */ 22 | declare module "@adonisjs/core/types" { 23 | export interface HashersList extends InferHashers {} 24 | } 25 | -------------------------------------------------------------------------------- /example.firebase_service_account_key.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "topwr-new-firebase", 4 | "private_key_id": "key_id", 5 | "private_key": "-----BEGIN PRIVATE KEY-----\nPK IN PEM FORMAT\n-----END PRIVATE KEY-----\n", 6 | "client_email": "service_account_email", 7 | "client_id": "client_id", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://oauth2.googleapis.com/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "cert_url", 12 | "universe_domain": "googleapis.com" 13 | } 14 | -------------------------------------------------------------------------------- /app/models/sks_user.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | import { BaseModel, column } from "@adonisjs/lucid/orm"; 4 | 5 | export default class SksUser extends BaseModel { 6 | @column.dateTime({ isPrimary: true }) 7 | declare externalTimestamp: DateTime; 8 | 9 | @column() 10 | // @example(21) 11 | declare activeUsers: number; 12 | 13 | @column() 14 | // @example(37) 15 | declare movingAverage21: number; 16 | 17 | @column.dateTime({ autoCreate: true }) 18 | declare createdAt: DateTime; 19 | 20 | @column.dateTime({ autoCreate: true, autoUpdate: true }) 21 | declare updatedAt: DateTime; 22 | } 23 | -------------------------------------------------------------------------------- /app/middleware/force_json_response_middleware.ts: -------------------------------------------------------------------------------- 1 | import type { HttpContext } from "@adonisjs/core/http"; 2 | import type { NextFn } from "@adonisjs/core/types/http"; 3 | 4 | /** 5 | * Updating the "Accept" header to always accept "application/json" response 6 | * from the server. This will force the internals of the framework like 7 | * validator errors or auth errors to return a JSON response. 8 | */ 9 | export default class ForceJsonResponseMiddleware { 10 | async handle({ request }: HttpContext, next: NextFn) { 11 | const headers = request.headers(); 12 | headers.accept = "application/json"; 13 | 14 | return next(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /database/migrations/1730994081410_create_sks_users_table.ts: -------------------------------------------------------------------------------- 1 | import { BaseSchema } from "@adonisjs/lucid/schema"; 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = "sks_users"; 5 | 6 | async up() { 7 | this.schema.createTable(this.tableName, (table) => { 8 | table.timestamp("external_timestamp").primary(); 9 | table.integer("active_users").notNullable(); 10 | table.integer("moving_average_21").notNullable(); 11 | 12 | table.timestamp("created_at"); 13 | table.timestamp("updated_at"); 14 | }); 15 | } 16 | 17 | async down() { 18 | this.schema.dropTable(this.tableName); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /config/database.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@adonisjs/lucid"; 2 | 3 | import env from "#start/env"; 4 | 5 | const dbConfig = defineConfig({ 6 | connection: "postgres", 7 | connections: { 8 | postgres: { 9 | client: "pg", 10 | connection: { 11 | host: env.get("DB_HOST"), 12 | port: env.get("DB_PORT"), 13 | user: env.get("DB_USER"), 14 | password: env.get("DB_PASSWORD"), 15 | database: env.get("DB_DATABASE"), 16 | }, 17 | migrations: { 18 | naturalSort: true, 19 | paths: ["database/migrations"], 20 | }, 21 | }, 22 | }, 23 | }); 24 | 25 | export default dbConfig; 26 | -------------------------------------------------------------------------------- /database/migrations/1750414002534_create_devices_table.ts: -------------------------------------------------------------------------------- 1 | import { BaseSchema } from "@adonisjs/lucid/schema"; 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = "devices"; 5 | 6 | async up() { 7 | this.schema.createTable(this.tableName, (table) => { 8 | table.text("device_key").primary(); 9 | table.text("registration_token").nullable(); 10 | table.timestamp("token_timestamp").nullable(); 11 | 12 | table.timestamp("created_at").notNullable(); 13 | table.timestamp("updated_at").notNullable(); 14 | }); 15 | } 16 | 17 | async down() { 18 | this.schema.dropTable(this.tableName); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /commands/docs_generate.ts: -------------------------------------------------------------------------------- 1 | import AutoSwagger from "adonis-autoswagger"; 2 | 3 | import { BaseCommand } from "@adonisjs/core/ace"; 4 | import { CommandOptions } from "@adonisjs/core/types/ace"; 5 | 6 | import swagger from "#config/swagger"; 7 | 8 | export default class DocsGenerate extends BaseCommand { 9 | static commandName = "docs:generate"; 10 | 11 | static options: CommandOptions = { 12 | startApp: true, 13 | allowUnknownFlags: false, 14 | staysAlive: false, 15 | }; 16 | 17 | async run() { 18 | const Router = await this.app.container.make("router"); 19 | Router.commit(); 20 | await AutoSwagger.default.writeFile(Router.toJSON(), swagger); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/unit/menuScrapper/scrape_menu.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | import { test } from "@japa/runner"; 5 | 6 | import { expectedResponse } from "#tests/fixtures/parsed_menu_expected_response"; 7 | 8 | import { parseMenu } from "../../../scripts/menu_scrapper.js"; 9 | 10 | test.group("Menu scrapper scrape menu", () => { 11 | test("should parse the external menu response", async ({ assert }) => { 12 | const htmlResponse = fs.readFileSync( 13 | path.resolve("./tests/fixtures/external_menu_response.html"), 14 | "utf8", 15 | ); 16 | 17 | const response = await parseMenu(htmlResponse); 18 | assert.deepEqual(response, expectedResponse); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /app/middleware/container_bindings_middleware.ts: -------------------------------------------------------------------------------- 1 | import { HttpContext } from "@adonisjs/core/http"; 2 | import { Logger } from "@adonisjs/core/logger"; 3 | import type { NextFn } from "@adonisjs/core/types/http"; 4 | 5 | /** 6 | * The container bindings middleware binds classes to their request 7 | * specific value using the container resolver. 8 | * 9 | * - We bind "HttpContext" class to the "ctx" object 10 | * - And bind "Logger" class to the "ctx.logger" object 11 | */ 12 | export default class ContainerBindingsMiddleware { 13 | handle(ctx: HttpContext, next: NextFn) { 14 | ctx.containerResolver.bindValue(HttpContext, ctx); 15 | ctx.containerResolver.bindValue(Logger, ctx.logger); 16 | 17 | return next(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/models/website_hash.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | import { BaseModel, column, hasMany } from "@adonisjs/lucid/orm"; 4 | import type { HasMany } from "@adonisjs/lucid/types/relations"; 5 | 6 | import HashesMeal from "./hashes_meal.js"; 7 | 8 | export default class WebsiteHash extends BaseModel { 9 | @column({ isPrimary: true }) 10 | // @example(80845fe1a68deadbb4febc3f6dbae98b64a3df7a1648edd417a8ece3164182f4) 11 | declare hash: string; 12 | 13 | @column.dateTime({ autoCreate: true }) 14 | declare createdAt: DateTime; 15 | 16 | @column.dateTime({ autoCreate: true, autoUpdate: true }) 17 | declare updatedAt: DateTime; 18 | 19 | @hasMany(() => HashesMeal) 20 | declare meals: HasMany; 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # this files includes some shit we've changed with czaja 2 | FROM node:20-bullseye-slim AS base 3 | 4 | RUN apt-get update && apt-get install -y \ 5 | curl \ 6 | wget \ 7 | && rm -rf /var/lib/apt/lists/* 8 | 9 | # All deps stage 10 | FROM base AS deps 11 | WORKDIR /app 12 | ADD package.json package-lock.json ./ 13 | RUN npm ci 14 | 15 | # Production only deps stage 16 | FROM base AS production-deps 17 | WORKDIR /app 18 | ADD package.json package-lock.json ./ 19 | RUN npm ci --omit=dev 20 | 21 | # Build stage 22 | FROM deps AS build 23 | WORKDIR /app 24 | ADD . . 25 | RUN node ace build 26 | 27 | # Production stage 28 | FROM production-deps 29 | ENV NODE_ENV=production 30 | WORKDIR /app 31 | 32 | # Copy docs 33 | COPY --from=build /app/build /app 34 | 35 | EXPOSE 8080 36 | 37 | # Set CMD to run the wrapper script 38 | CMD ["sh", "/app/start.sh"] 39 | -------------------------------------------------------------------------------- /app/controllers/info_controller.ts: -------------------------------------------------------------------------------- 1 | export default class InfoController { 2 | /** 3 | * @openingHours 4 | * @summary Get opening hours for canteen and cafe 5 | * @description Retrieves the opening and closing times for the canteen and cafe. 6 | * @responseBody 200 - {"openingHours":{"canteen":{"openingTime":"string","closingTime":"string"},"cafe":{"openingTime":"string","closingTime":"string"}}} 7 | * @responseBody 500 - {"message":"string","error":"string"} 8 | */ 9 | async openingHours() { 10 | return { 11 | openingHours: { 12 | canteen: { 13 | openingTime: "pon.-czw. 10:30", 14 | closingTime: "17:00, pt. 10:30 - 16:00", 15 | }, 16 | cafe: { 17 | openingTime: "pon.-czw. 7:30", 18 | closingTime: "18:00, pt. 7:30 - 16:00", 19 | }, 20 | }, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /database/migrations/1750414928067_create_subscriptions_table.ts: -------------------------------------------------------------------------------- 1 | import { BaseSchema } from "@adonisjs/lucid/schema"; 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = "subscriptions"; 5 | 6 | async up() { 7 | this.schema.createTable(this.tableName, (table) => { 8 | table 9 | .bigInteger("meal_id") 10 | .unsigned() 11 | .references("id") 12 | .inTable("meals") 13 | .onDelete("CASCADE"); 14 | table 15 | .string("device_key") 16 | .unsigned() 17 | .references("device_key") 18 | .inTable("devices") 19 | .onDelete("CASCADE"); 20 | 21 | table.timestamp("created_at").notNullable(); 22 | 23 | table.primary(["meal_id", "device_key"]); 24 | }); 25 | } 26 | 27 | async down() { 28 | this.schema.dropTable(this.tableName); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ace.js: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | JavaScript entrypoint for running ace commands 4 | |-------------------------------------------------------------------------- 5 | | 6 | | DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD 7 | | PROCESS. 8 | | 9 | | See docs.adonisjs.com/guides/typescript-build-process#creating-production-build 10 | | 11 | | Since, we cannot run TypeScript source code using "node" binary, we need 12 | | a JavaScript entrypoint to run ace commands. 13 | | 14 | | This file registers the "ts-node/esm" hook with the Node.js module system 15 | | and then imports the "bin/console.ts" file. 16 | | 17 | */ 18 | /** 19 | * Register hook to process TypeScript files using ts-node 20 | */ 21 | import "ts-node-maintained/register/esm"; 22 | 23 | /** 24 | * Import ace console entrypoint 25 | */ 26 | await import("./bin/console.js"); 27 | -------------------------------------------------------------------------------- /database/migrations/1730118314055_create_meals_table.ts: -------------------------------------------------------------------------------- 1 | import { BaseSchema } from "@adonisjs/lucid/schema"; 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = "meals"; 5 | protected readonly mealCategories = [ 6 | "SALAD", 7 | "SOUP", 8 | "VEGETARIAN_DISH", 9 | "MEAT_DISH", 10 | "DESSERT", 11 | "SIDE_DISH", 12 | "DRINK", 13 | "TECHNICAL_INFO", 14 | ]; 15 | 16 | async up() { 17 | this.schema.createTable(this.tableName, (table) => { 18 | table.bigIncrements("id"); 19 | 20 | table.text("name").notNullable(); 21 | table.enum("category", this.mealCategories, { 22 | useNative: true, 23 | enumName: "meal_category", 24 | existingType: false, 25 | }); 26 | 27 | table.timestamp("created_at"); 28 | table.timestamp("updated_at"); 29 | }); 30 | } 31 | 32 | async down() { 33 | this.schema.dropTable(this.tableName); 34 | this.schema.raw('DROP TYPE IF EXISTS "meal_category"'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/1731367612817_create_hashes_meals_table.ts: -------------------------------------------------------------------------------- 1 | import { BaseSchema } from "@adonisjs/lucid/schema"; 2 | 3 | export default class extends BaseSchema { 4 | protected tableName = "hashes_meals"; 5 | 6 | async up() { 7 | this.schema.createTable(this.tableName, (table) => { 8 | table.text("hash_fk").unsigned(); 9 | table.bigInteger("meal_id").unsigned(); 10 | 11 | table 12 | .foreign("hash_fk") 13 | .references("hash") 14 | .inTable("website_hashes") 15 | .onDelete("CASCADE"); 16 | table 17 | .foreign("meal_id") 18 | .references("id") 19 | .inTable("meals") 20 | .onDelete("CASCADE"); 21 | table.primary(["hash_fk", "meal_id"]); 22 | 23 | table.text("size"); 24 | table.decimal("price", 4, 2).notNullable(); 25 | 26 | table.timestamp("created_at"); 27 | table.timestamp("updated_at"); 28 | }); 29 | } 30 | 31 | async down() { 32 | this.schema.dropTable(this.tableName); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /config/logger.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, targets } from "@adonisjs/core/logger"; 2 | import app from "@adonisjs/core/services/app"; 3 | 4 | import env from "#start/env"; 5 | 6 | const loggerConfig = defineConfig({ 7 | default: "app", 8 | 9 | /** 10 | * The loggers object can be used to define multiple loggers. 11 | * By default, we configure only one logger (named "app"). 12 | */ 13 | loggers: { 14 | app: { 15 | enabled: true, 16 | name: env.get("APP_NAME"), 17 | level: env.get("LOG_LEVEL"), 18 | transport: { 19 | targets: targets() 20 | .pushIf(!app.inProduction, targets.pretty()) 21 | .pushIf(app.inProduction, targets.file({ destination: 1 })) 22 | .toArray(), 23 | }, 24 | }, 25 | }, 26 | }); 27 | 28 | export default loggerConfig; 29 | 30 | /** 31 | * Inferring types for the list of loggers you have configured 32 | * in your application. 33 | */ 34 | declare module "@adonisjs/core/types" { 35 | export interface LoggersList extends InferLoggers {} 36 | } 37 | -------------------------------------------------------------------------------- /app/models/hashes_meal.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | import { BaseModel, belongsTo, column } from "@adonisjs/lucid/orm"; 4 | import type { BelongsTo } from "@adonisjs/lucid/types/relations"; 5 | 6 | import Meal from "./meal.js"; 7 | import WebsiteHash from "./website_hash.js"; 8 | 9 | export default class HashesMeal extends BaseModel { 10 | @column({ isPrimary: true }) 11 | // @example(80845fe1a68deadbb4febc3f6dbae98b64a3df7a1648edd417a8ece3164182f4) 12 | declare hashFk: string; 13 | 14 | @column({ isPrimary: true }) 15 | declare mealId: number; 16 | 17 | @column() 18 | // @example(200g/10g) 19 | declare size: string | null; 20 | 21 | @column() 22 | // @example(21.00) 23 | declare price: number; 24 | 25 | @column.dateTime({ autoCreate: true }) 26 | declare createdAt: DateTime; 27 | 28 | @belongsTo(() => Meal) 29 | // @no-swagger 30 | declare meal: BelongsTo; 31 | 32 | @belongsTo(() => WebsiteHash, { foreignKey: "hashFk" }) 33 | // @no-swagger 34 | declare websiteHash: BelongsTo; 35 | } 36 | -------------------------------------------------------------------------------- /start/scheduler.ts: -------------------------------------------------------------------------------- 1 | import scheduler from "adonisjs-scheduler/services/main"; 2 | 3 | import logger from "@adonisjs/core/services/logger"; 4 | 5 | import { runScrapper as runMenuScrapper } from "../scripts/menu_scrapper.js"; 6 | import { runScrapper as runUsersScrapper } from "../scripts/users_scrapper.js"; 7 | import env from "./env.js"; 8 | 9 | scheduler 10 | .call(() => { 11 | if (env.get("RUN_MENU_SCRAPPER")) { 12 | logger.info("Running menu scraper."); 13 | void runMenuScrapper(); 14 | } else { 15 | logger.info( 16 | "Menu scrapper is disabled by the RUN_MENU_SCRAPPER env flag.", 17 | ); 18 | } 19 | }) 20 | .immediate() 21 | .everyFifteenMinutes(); 22 | // JDI 23 | // .everyFiveMinutes(); 24 | 25 | scheduler 26 | .call(() => { 27 | if (env.get("RUN_USERS_SCRAPPER")) { 28 | logger.info("Running users scraper."); 29 | void runUsersScrapper(); 30 | } else { 31 | logger.info( 32 | "Users scrapper is disabled by the RUN_USERS_SCRAPPER env flag.", 33 | ); 34 | } 35 | }) 36 | .immediate() 37 | .everyMinute(); 38 | -------------------------------------------------------------------------------- /.github/workflows/db.yml: -------------------------------------------------------------------------------- 1 | name: Migration check 2 | 3 | on: 4 | pull_request: 5 | branches: ["*"] 6 | push: 7 | branches: ["main"] 8 | 9 | jobs: 10 | migration-check: 11 | runs-on: ubuntu-latest 12 | env: 13 | DB_HOST: 127.0.0.1 14 | DB_PORT: 5432 15 | DB_USER: postgres 16 | DB_PASSWORD: postgres 17 | DB_DATABASE: postgres 18 | 19 | services: 20 | postgres: 21 | image: postgres 22 | env: 23 | POSTGRES_PASSWORD: postgres 24 | options: >- 25 | --health-cmd pg_isready 26 | --health-interval 10s 27 | --health-timeout 5s 28 | --health-retries 5 29 | ports: 30 | - 5432:5432 31 | 32 | steps: 33 | - name: Check out repository code 34 | uses: actions/checkout@v4 35 | 36 | - name: Install dependencies 37 | run: npm ci 38 | 39 | - name: Set up AdonisJS environment 40 | run: | 41 | cp .env.example .env 42 | node ace generate:key 43 | 44 | - name: Run AdonisJS migrations 45 | run: node ace migration:run 46 | 47 | - name: Rollback and rerun AdonisJS migrations 48 | run: node ace migration:refresh 49 | -------------------------------------------------------------------------------- /config/app.ts: -------------------------------------------------------------------------------- 1 | import { Secret } from "@adonisjs/core/helpers"; 2 | import { defineConfig } from "@adonisjs/core/http"; 3 | import app from "@adonisjs/core/services/app"; 4 | 5 | import env from "#start/env"; 6 | 7 | /** 8 | * The app key is used for encrypting cookies, generating signed URLs, 9 | * and by the "encryption" module. 10 | * 11 | * The encryption module will fail to decrypt data if the key is lost or 12 | * changed. Therefore it is recommended to keep the app key secure. 13 | */ 14 | export const appKey = new Secret(env.get("APP_KEY")); 15 | 16 | /** 17 | * The configuration settings used by the HTTP server 18 | */ 19 | export const http = defineConfig({ 20 | generateRequestId: true, 21 | allowMethodSpoofing: false, 22 | 23 | /** 24 | * Enabling async local storage will let you access HTTP context 25 | * from anywhere inside your application. 26 | */ 27 | useAsyncLocalStorage: false, 28 | 29 | /** 30 | * Manage cookies configuration. The settings for the session id cookie are 31 | * defined inside the "config/session.ts" file. 32 | */ 33 | cookie: { 34 | domain: "", 35 | path: "/", 36 | maxAge: "2h", 37 | httpOnly: true, 38 | secure: app.inProduction, 39 | sameSite: "lax", 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /tests/fixtures/parsed_menu_expected_response.ts: -------------------------------------------------------------------------------- 1 | export const expectedResponse = [ 2 | { 3 | name: "Surówka z selera z rodzynkami", 4 | size: "100g", 5 | price: 4, 6 | category: "SALAD", 7 | }, 8 | { 9 | name: "Kapuśniak z białej kapusty", 10 | size: "300ml", 11 | price: 6.5, 12 | category: "SOUP", 13 | }, 14 | { 15 | name: "Papryka fasz.k.maz.mozzar.i p.susz", 16 | size: "300g", 17 | price: 15, 18 | category: "VEGETARIAN_DISH", 19 | }, 20 | { 21 | name: "Pampuchy drożdż. z sosem trus", 22 | size: "250/50", 23 | price: 15, 24 | category: "VEGETARIAN_DISH", 25 | }, 26 | { 27 | name: "Pieczeń z karczku w natur.sosie", 28 | size: "110g/50g", 29 | price: 15, 30 | category: "MEAT_DISH", 31 | }, 32 | { 33 | name: "Stek drobiowy w s. pieczarkowym", 34 | size: "120/50g", 35 | price: 15, 36 | category: "MEAT_DISH", 37 | }, 38 | { 39 | name: "Ryż biały na sypko", 40 | size: "-", 41 | price: 4, 42 | category: "SIDE_DISH", 43 | }, 44 | { 45 | name: "Ziemniaki z koperkiem", 46 | size: "250g", 47 | price: 4.5, 48 | category: "SIDE_DISH", 49 | }, 50 | { 51 | name: "Napój z soku jabłkowego", 52 | size: "200ml", 53 | price: 2.5, 54 | category: "DRINK", 55 | }, 56 | ]; 57 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Setup node 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 22 21 | cache: "npm" 22 | 23 | - name: Install dependencies 24 | run: npm ci 25 | 26 | - name: Set up AdonisJS environment 27 | run: | 28 | cp .env.example .env 29 | node ace generate:key 30 | 31 | - name: Check commit name 32 | if: github.event_name == 'pull_request' 33 | run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose 34 | 35 | - name: Run prettier 36 | run: npm run format:check 37 | if: always() 38 | 39 | - name: Run Lint 40 | run: npm run lint 41 | if: always() 42 | 43 | - name: Check types 44 | run: npm run typecheck 45 | if: always() 46 | 47 | - name: Run tests 48 | run: npm test 49 | if: always() 50 | 51 | - name: Build 52 | run: npm run build 53 | if: always() 54 | -------------------------------------------------------------------------------- /app/exceptions/handler.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { ExceptionHandler, HttpContext, Response } from "@adonisjs/core/http"; 4 | import app from "@adonisjs/core/services/app"; 5 | 6 | export default class HttpExceptionHandler extends ExceptionHandler { 7 | /** 8 | * In debug mode, the exception handler will display verbose errors 9 | * with pretty printed stack traces. 10 | */ 11 | protected debug = !app.inProduction; 12 | 13 | /** 14 | * The method is used for handling errors and returning 15 | * response to the client 16 | */ 17 | async handle(error: unknown, ctx: HttpContext) { 18 | return super.handle(error, ctx); 19 | } 20 | 21 | /** 22 | * The method is used to report error to the logging service or 23 | * the third party error monitoring service. 24 | * 25 | * @note You should not attempt to send a response from this method. 26 | */ 27 | async report(error: unknown, ctx: HttpContext) { 28 | return super.report(error, ctx); 29 | } 30 | } 31 | 32 | export function handleError(error: unknown, response: Response) { 33 | if (error instanceof z.ZodError) { 34 | return response.badRequest({ 35 | message: "Invalid input", 36 | error: error.message, 37 | }); 38 | } 39 | return response.internalServerError({ 40 | message: "Server error", 41 | error: (error as Error).message, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /start/kernel.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | HTTP kernel file 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The HTTP kernel file is used to register the middleware with the server 7 | | or the router. 8 | | 9 | */ 10 | import router from "@adonisjs/core/services/router"; 11 | import server from "@adonisjs/core/services/server"; 12 | 13 | /** 14 | * The error handler is used to convert an exception 15 | * to a HTTP response. 16 | */ 17 | server.errorHandler(() => import("#exceptions/handler")); 18 | 19 | /** 20 | * The server middleware stack runs middleware on all the HTTP 21 | * requests, even if there is no route registered for 22 | * the request URL. 23 | */ 24 | server.use([ 25 | () => import("@solvro/solvronis-metrics"), 26 | () => import("#middleware/container_bindings_middleware"), 27 | () => import("#middleware/force_json_response_middleware"), 28 | () => import("@adonisjs/cors/cors_middleware"), 29 | ]); 30 | 31 | /** 32 | * The router middleware stack runs middleware on all the HTTP 33 | * requests with a registered route. 34 | */ 35 | router.use([() => import("@adonisjs/core/bodyparser_middleware")]); 36 | 37 | /** 38 | * Named middleware collection must be explicitly assigned to 39 | * the routes or the routes group. 40 | */ 41 | export const middleware = router.named({}); 42 | -------------------------------------------------------------------------------- /tests/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "@japa/api-client"; 2 | import { assert } from "@japa/assert"; 3 | import { pluginAdonisJS } from "@japa/plugin-adonisjs"; 4 | import type { Config } from "@japa/runner/types"; 5 | 6 | import app from "@adonisjs/core/services/app"; 7 | import testUtils from "@adonisjs/core/services/test_utils"; 8 | 9 | /** 10 | * This file is imported by the "bin/test.ts" entrypoint file 11 | */ 12 | 13 | /** 14 | * Configure Japa plugins in the plugins array. 15 | * Learn more - https://japa.dev/docs/runner-config#plugins-optional 16 | */ 17 | export const plugins: Config["plugins"] = [ 18 | assert(), 19 | apiClient(), 20 | pluginAdonisJS(app), 21 | ]; 22 | 23 | /** 24 | * Configure lifecycle function to run before and after all the 25 | * tests. 26 | * 27 | * The setup functions are executed before all the tests 28 | * The teardown functions are executed after all the tests 29 | */ 30 | export const runnerHooks: Required> = { 31 | setup: [], 32 | teardown: [], 33 | }; 34 | 35 | /** 36 | * Configure suites by tapping into the test suite instance. 37 | * Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks 38 | */ 39 | export const configureSuite: Config["configureSuite"] = (suite) => { 40 | if (["browser", "functional", "e2e"].includes(suite.name)) { 41 | return suite.setup(() => testUtils.httpServer().start()); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /bin/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | HTTP server entrypoint 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The "server.ts" file is the entrypoint for starting the AdonisJS HTTP 7 | | server. Either you can run this file directly or use the "serve" 8 | | command to run this file and monitor file changes 9 | | 10 | */ 11 | import "reflect-metadata"; 12 | 13 | import { Ignitor, prettyPrintError } from "@adonisjs/core"; 14 | 15 | /** 16 | * URL to the application root. AdonisJS need it to resolve 17 | * paths to file and directories for scaffolding commands 18 | */ 19 | const APP_ROOT = new URL("../", import.meta.url); 20 | 21 | /** 22 | * The importer is used to import files in context of the 23 | * application. 24 | */ 25 | const IMPORTER = (filePath: string) => { 26 | if (filePath.startsWith("./") || filePath.startsWith("../")) { 27 | return import(new URL(filePath, APP_ROOT).href); 28 | } 29 | return import(filePath); 30 | }; 31 | 32 | new Ignitor(APP_ROOT, { importer: IMPORTER }) 33 | .tap((app) => { 34 | app.booting(async () => { 35 | await import("#start/env"); 36 | }); 37 | app.listen("SIGTERM", () => app.terminate()); 38 | app.listenIf(app.managedByPm2, "SIGINT", () => app.terminate()); 39 | }) 40 | .httpServer() 41 | .start() 42 | .catch((error) => { 43 | process.exitCode = 1; 44 | void prettyPrintError(error); 45 | }); 46 | -------------------------------------------------------------------------------- /config/bodyparser.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@adonisjs/core/bodyparser"; 2 | 3 | const bodyParserConfig = defineConfig({ 4 | /** 5 | * The bodyparser middleware will parse the request body 6 | * for the following HTTP methods. 7 | */ 8 | allowedMethods: ["POST", "PUT", "PATCH", "DELETE"], 9 | 10 | /** 11 | * Config for the "application/x-www-form-urlencoded" 12 | * content-type parser 13 | */ 14 | form: { 15 | convertEmptyStringsToNull: true, 16 | types: ["application/x-www-form-urlencoded"], 17 | }, 18 | 19 | /** 20 | * Config for the JSON parser 21 | */ 22 | json: { 23 | convertEmptyStringsToNull: true, 24 | types: [ 25 | "application/json", 26 | "application/json-patch+json", 27 | "application/vnd.api+json", 28 | "application/csp-report", 29 | ], 30 | }, 31 | 32 | /** 33 | * Config for the "multipart/form-data" content-type parser. 34 | * File uploads are handled by the multipart parser. 35 | */ 36 | multipart: { 37 | /** 38 | * Enabling auto process allows bodyparser middleware to 39 | * move all uploaded files inside the tmp folder of your 40 | * operating system 41 | */ 42 | autoProcess: true, 43 | convertEmptyStringsToNull: true, 44 | processManually: [], 45 | 46 | /** 47 | * Maximum limit of data to parse including all files 48 | * and fields 49 | */ 50 | limit: "20mb", 51 | types: ["multipart/form-data"], 52 | }, 53 | }); 54 | 55 | export default bodyParserConfig; 56 | -------------------------------------------------------------------------------- /start/env.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Environment variables service 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The `Env.create` method creates an instance of the Env service. The 7 | | service validates the environment variables and also cast values 8 | | to JavaScript data types. 9 | | 10 | */ 11 | import { Env } from "@adonisjs/core/env"; 12 | 13 | export default await Env.create(new URL("../", import.meta.url), { 14 | NODE_ENV: Env.schema.enum(["development", "production", "test"] as const), 15 | PORT: Env.schema.number(), 16 | APP_KEY: Env.schema.string(), 17 | HOST: Env.schema.string({ format: "host" }), 18 | LOG_LEVEL: Env.schema.enum([ 19 | "fatal", 20 | "error", 21 | "warn", 22 | "info", 23 | "debug", 24 | "trace", 25 | ]), 26 | 27 | /* 28 | |---------------------------------------------------------- 29 | | Variables for configuring database connection 30 | |---------------------------------------------------------- 31 | */ 32 | DB_HOST: Env.schema.string({ format: "host" }), 33 | DB_PORT: Env.schema.number(), 34 | DB_USER: Env.schema.string(), 35 | DB_PASSWORD: Env.schema.string.optional(), 36 | DB_DATABASE: Env.schema.string(), 37 | 38 | // Custom variables 39 | MENU_URL: Env.schema.string({ format: "url" }), 40 | USERS_URL: Env.schema.string({ format: "url" }), 41 | 42 | RUN_MENU_SCRAPPER: Env.schema.boolean(), 43 | RUN_USERS_SCRAPPER: Env.schema.boolean(), 44 | }); 45 | -------------------------------------------------------------------------------- /bin/console.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Ace entry point 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The "console.ts" file is the entrypoint for booting the AdonisJS 7 | | command-line framework and executing commands. 8 | | 9 | | Commands do not boot the application, unless the currently running command 10 | | has "options.startApp" flag set to true. 11 | | 12 | */ 13 | import "reflect-metadata"; 14 | 15 | import { Ignitor, prettyPrintError } from "@adonisjs/core"; 16 | 17 | /** 18 | * URL to the application root. AdonisJS need it to resolve 19 | * paths to file and directories for scaffolding commands 20 | */ 21 | const APP_ROOT = new URL("../", import.meta.url); 22 | 23 | /** 24 | * The importer is used to import files in context of the 25 | * application. 26 | */ 27 | const IMPORTER = (filePath: string) => { 28 | if (filePath.startsWith("./") || filePath.startsWith("../")) { 29 | return import(new URL(filePath, APP_ROOT).href); 30 | } 31 | return import(filePath); 32 | }; 33 | 34 | new Ignitor(APP_ROOT, { importer: IMPORTER }) 35 | .tap((app) => { 36 | app.booting(async () => { 37 | await import("#start/env"); 38 | }); 39 | app.listen("SIGTERM", () => app.terminate()); 40 | app.listenIf(app.managedByPm2, "SIGINT", () => app.terminate()); 41 | }) 42 | .ace() 43 | .handle(process.argv.splice(2)) 44 | .catch((error) => { 45 | process.exitCode = 1; 46 | void prettyPrintError(error); 47 | }); 48 | -------------------------------------------------------------------------------- /app/models/meal.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | import { BaseModel, column, hasMany, manyToMany } from "@adonisjs/lucid/orm"; 4 | import type { HasMany, ManyToMany } from "@adonisjs/lucid/types/relations"; 5 | 6 | import Device from "#models/device"; 7 | 8 | import HashesMeal from "./hashes_meal.js"; 9 | 10 | export enum MealCategory { 11 | Salad = "SALAD", 12 | Soup = "SOUP", 13 | VegetarianDish = "VEGETARIAN_DISH", 14 | MeatDish = "MEAT_DISH", 15 | Dessert = "DESSERT", 16 | SideDish = "SIDE_DISH", 17 | Drink = "DRINK", 18 | TechnicalInfo = "TECHNICAL_INFO", 19 | } 20 | 21 | export default class Meal extends BaseModel { 22 | @column({ isPrimary: true }) 23 | declare id: number; 24 | 25 | @column() 26 | // @example(Frytki z batatów) 27 | declare name: string; 28 | 29 | @column() 30 | // I know it is made badly but unfortunately autoswagger does not support enums 31 | // @example(SALAD, SOUP, VEGETARIAN_DISH, MEAT_DISH, DESSERT, SIDE_DISH, DRINK, TECHNICAL_INFO) 32 | declare category: MealCategory | null; 33 | 34 | @column.dateTime({ autoCreate: true }) 35 | declare createdAt: DateTime; 36 | 37 | @column.dateTime({ autoCreate: true, autoUpdate: true }) 38 | declare updatedAt: DateTime; 39 | 40 | @hasMany(() => HashesMeal) 41 | declare hashes: HasMany; 42 | 43 | @manyToMany(() => Device, { 44 | pivotTable: "subscriptions", 45 | localKey: "id", 46 | pivotForeignKey: "meal_id", 47 | relatedKey: "deviceKey", 48 | pivotRelatedForeignKey: "device_key", 49 | pivotTimestamps: true, 50 | }) 51 | declare devices: ManyToMany; 52 | } 53 | -------------------------------------------------------------------------------- /config/swagger.ts: -------------------------------------------------------------------------------- 1 | // for AdonisJS v6 2 | import path from "node:path"; 3 | import url from "node:url"; 4 | 5 | // --- 6 | 7 | export default { 8 | // path: __dirname + "/../", for AdonisJS v5 9 | path: `${path.dirname(url.fileURLToPath(import.meta.url))}/../`, // for AdonisJS v6 10 | title: "Foo", // use info instead 11 | version: "1.0.0", // use info instead 12 | description: "", // use info instead 13 | tagIndex: 3, 14 | info: { 15 | title: "SKS-Scrapper", 16 | version: "1.0.0", 17 | description: 18 | "The SKS Menu Scraper is a tool that automatically fetches and parses canteen menu data, including dish names, portion sizes, and prices, storing the information in a database. It also features a wrapper for tracking the number of canteen users, providing a comprehensive solution for managing both menu and user data through a RESTful API.", 19 | }, 20 | snakeCase: true, 21 | 22 | debug: false, // set to true, to get some useful debug output 23 | ignore: [ 24 | "/api/v1/swagger", 25 | "/api/v1/docs", 26 | "/", 27 | "/api", 28 | "/api/v1", 29 | "/api/docs", 30 | "/docs", 31 | ], 32 | preferredPutPatch: "PUT", // if PUT/PATCH are provided for the same route, prefer PUT 33 | common: { 34 | parameters: {}, // OpenAPI conform parameters that are commonly used 35 | headers: {}, // OpenAPI conform headers that are commonly used 36 | }, 37 | securitySchemes: {}, // optional 38 | authMiddlewares: ["auth", "auth:api"], // optional 39 | defaultSecurityScheme: "BearerAuth", // optional 40 | persistAuthorization: true, // persist authorization between reloads on the swagger page 41 | showFullPath: false, // the path displayed after endpoint summary 42 | }; 43 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | 3 | import { configure, processCLIArgs, run } from "@japa/runner"; 4 | 5 | import { Ignitor, prettyPrintError } from "@adonisjs/core"; 6 | 7 | /* 8 | |-------------------------------------------------------------------------- 9 | | Test runner entrypoint 10 | |-------------------------------------------------------------------------- 11 | | 12 | | The "test.ts" file is the entrypoint for running tests using Japa. 13 | | 14 | | Either you can run this file directly or use the "test" 15 | | command to run this file and monitor file changes. 16 | | 17 | */ 18 | 19 | process.env.NODE_ENV = "test"; 20 | 21 | /** 22 | * URL to the application root. AdonisJS need it to resolve 23 | * paths to file and directories for scaffolding commands 24 | */ 25 | const APP_ROOT = new URL("../", import.meta.url); 26 | 27 | /** 28 | * The importer is used to import files in context of the 29 | * application. 30 | */ 31 | const IMPORTER = (filePath: string) => { 32 | if (filePath.startsWith("./") || filePath.startsWith("../")) { 33 | return import(new URL(filePath, APP_ROOT).href); 34 | } 35 | return import(filePath); 36 | }; 37 | 38 | new Ignitor(APP_ROOT, { importer: IMPORTER }) 39 | .tap((app) => { 40 | app.booting(async () => { 41 | await import("#start/env"); 42 | }); 43 | app.listen("SIGTERM", () => app.terminate()); 44 | app.listenIf(app.managedByPm2, "SIGINT", () => app.terminate()); 45 | }) 46 | .testRunner() 47 | .configure(async (app) => { 48 | const { runnerHooks, ...config } = await import("../tests/bootstrap.js"); 49 | 50 | processCLIArgs(process.argv.splice(2)); 51 | configure({ 52 | ...app.rcFile.tests, 53 | ...config, 54 | ...{ 55 | setup: runnerHooks.setup, 56 | teardown: runnerHooks.teardown.concat([() => app.terminate()]), 57 | }, 58 | }); 59 | }) 60 | .run(() => run()) 61 | .catch((error) => { 62 | process.exitCode = 1; 63 | void prettyPrintError(error); 64 | }); 65 | -------------------------------------------------------------------------------- /app/models/device.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | import { BaseModel, column, manyToMany } from "@adonisjs/lucid/orm"; 4 | import type { ManyToMany } from "@adonisjs/lucid/types/relations"; 5 | 6 | import Meal from "#models/meal"; 7 | 8 | export const TOKEN_EXPIRATION_TIME_MS = 1000 * 60 * 60 * 24 * 270; // 270 days - mirroring the Firebase 9 | 10 | /** 11 | * @param tokenTimestamp device.tokenTimestamp 12 | * @param relativeTime Timestamp relative to which calculations will be performed 13 | * @returns Token expiration time in millis. If negative, the token is invalid 14 | */ 15 | export function getTokenExpirationTime( 16 | tokenTimestamp: number, 17 | relativeTime: number, 18 | ): number { 19 | return TOKEN_EXPIRATION_TIME_MS + relativeTime - tokenTimestamp; 20 | } 21 | 22 | export default class Device extends BaseModel { 23 | @column({ isPrimary: true }) 24 | declare deviceKey: string; 25 | 26 | @column() 27 | declare registrationToken: string | null; 28 | 29 | @column.dateTime() 30 | declare tokenTimestamp: DateTime | null; 31 | 32 | @column.dateTime({ autoCreate: true }) 33 | declare createdAt: DateTime; 34 | 35 | @column.dateTime({ autoCreate: true, autoUpdate: true }) 36 | declare updatedAt: DateTime; 37 | 38 | @manyToMany(() => Meal, { 39 | pivotTable: "subscriptions", 40 | localKey: "deviceKey", 41 | pivotForeignKey: "device_key", 42 | relatedKey: "id", 43 | pivotRelatedForeignKey: "meal_id", 44 | pivotTimestamps: true, 45 | }) 46 | declare meals: ManyToMany; 47 | 48 | public static async updateTokenTimestamps(ids: string[]) { 49 | return Device.query() 50 | .update({ 51 | tokenTimestamp: DateTime.now(), 52 | }) 53 | .whereIn("device_key", ids); 54 | } 55 | 56 | public static async removeTokens(ids: string[]) { 57 | return Device.query() 58 | .update({ 59 | registrationToken: null, 60 | tokenTimestamp: null, 61 | }) 62 | .whereIn("device_key", ids); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /scripts/users_scrapper.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import assert from "node:assert"; 3 | 4 | import logger from "@adonisjs/core/services/logger"; 5 | import db from "@adonisjs/lucid/services/db"; 6 | 7 | import env from "#start/env"; 8 | 9 | const url = env.get("USERS_URL"); 10 | 11 | export async function runScrapper() { 12 | try { 13 | const response = await fetch(url); 14 | if (!response.ok) { 15 | logger.error( 16 | `Failed to fetch data: ${response.status} ${response.statusText}`, 17 | ); 18 | return; 19 | } 20 | 21 | const usersData = await response.text(); 22 | const currentDateTime = DateTime.now().setZone("Europe/Warsaw"); 23 | 24 | const rows = usersData.trim().split("\n"); 25 | 26 | const values = rows.map((row) => { 27 | const [time, movingAverage21, activeUsers] = row.split(";"); 28 | const timestamp = currentDateTime.set({ 29 | hour: Number.parseInt(time.split(":")[0], 10), 30 | minute: Number.parseInt(time.split(":")[1], 10), 31 | second: 0, 32 | millisecond: 0, 33 | }); 34 | 35 | return { 36 | timestamp, 37 | activeUsers: Number(activeUsers), 38 | movingAverage21: Number(movingAverage21), 39 | }; 40 | }); 41 | 42 | const query = ` 43 | INSERT INTO sks_users (external_timestamp, active_users, moving_average_21, created_at, updated_at) 44 | VALUES ${values.map(() => "(?, ?, ?, NOW(), NOW())").join(", ")} 45 | ON CONFLICT (external_timestamp) DO UPDATE SET 46 | active_users = EXCLUDED.active_users, 47 | moving_average_21 = EXCLUDED.moving_average_21, 48 | updated_at = NOW() 49 | `; 50 | 51 | const parameters = values.flatMap( 52 | ({ timestamp, activeUsers, movingAverage21 }) => [ 53 | timestamp, 54 | activeUsers, 55 | movingAverage21, 56 | ], 57 | ); 58 | 59 | await db.rawQuery(query, parameters); 60 | logger.info(`SKS users data updated successfully.`); 61 | } catch (error) { 62 | assert(error instanceof Error); 63 | logger.error( 64 | `Failed to update sks_users data: ${error.message}`, 65 | error.stack, 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /start/routes.ts: -------------------------------------------------------------------------------- 1 | import AutoSwagger from "adonis-autoswagger"; 2 | 3 | import router from "@adonisjs/core/services/router"; 4 | 5 | import swagger from "#config/swagger"; 6 | 7 | const MealsController = () => import("#controllers/meals_controller"); 8 | const SksUsersController = () => import("#controllers/sks_users_controller"); 9 | const InfoController = () => import("#controllers/info_controller"); 10 | const RegistrationTokensController = () => 11 | import("#controllers/registration_tokens_controller"); 12 | const SubscriptionsController = () => 13 | import("#controllers/subscriptions_controller"); 14 | 15 | router 16 | .group(() => { 17 | router.get("/meals", [MealsController, "index"]); 18 | router.get("/meals/recent", [MealsController, "recent"]); 19 | router.get("/meals/current", [MealsController, "current"]); 20 | 21 | router.get("/sks-users/current", [SksUsersController, "latest"]); 22 | router.get("/sks-users/today", [SksUsersController, "today"]); 23 | 24 | router.get("/info", [InfoController, "openingHours"]); 25 | 26 | router.put("/device/registration-token", [ 27 | RegistrationTokensController, 28 | "updateOrCreate", 29 | ]); 30 | router.get("/device/registration-token/:deviceKey", [ 31 | RegistrationTokensController, 32 | "hasToken", 33 | ]); 34 | 35 | router.post("/subscriptions/toggle", [SubscriptionsController, "toggle"]); 36 | router.get("/subscriptions/:deviceKey", [ 37 | SubscriptionsController, 38 | "listForDevice", 39 | ]); 40 | 41 | // returns swagger in YAML 42 | router.get("/swagger", async () => { 43 | return AutoSwagger.default.docs(router.toJSON(), swagger); 44 | }); 45 | 46 | // Renders Swagger-UI and passes YAML-output of /swagger 47 | router.get("/docs", async () => { 48 | return AutoSwagger.default.ui("/api/v1/swagger", swagger); 49 | }); 50 | 51 | router.get("/healthcheck", async () => { 52 | return "elo żelo"; 53 | }); 54 | }) 55 | .prefix("/api/v1"); 56 | 57 | router.get("/metrics", [ 58 | () => import("@solvro/solvronis-metrics"), 59 | "emitMetrics", 60 | ]); 61 | 62 | // Reroute some paths to docs 63 | const redirectPaths = ["/", "/api", "/api/v1", "/api/docs", "/docs"]; 64 | redirectPaths.forEach((path) => { 65 | router.get(path, async ({ response }) => { 66 | return response.redirect("/api/v1/docs"); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /adonisrc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@adonisjs/core/app"; 2 | 3 | export default defineConfig({ 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Commands 7 | |-------------------------------------------------------------------------- 8 | | 9 | | List of ace commands to register from packages. The application commands 10 | | will be scanned automatically from the "./commands" directory. 11 | | 12 | */ 13 | commands: [ 14 | () => import("@adonisjs/core/commands"), 15 | () => import("@adonisjs/lucid/commands"), 16 | () => import("adonisjs-scheduler/commands"), 17 | ], 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Service providers 22 | |-------------------------------------------------------------------------- 23 | | 24 | | List of service providers to import and register when booting the 25 | | application 26 | | 27 | */ 28 | providers: [ 29 | () => import("@adonisjs/core/providers/app_provider"), 30 | () => import("@adonisjs/core/providers/hash_provider"), 31 | { 32 | file: () => import("@adonisjs/core/providers/repl_provider"), 33 | environment: ["repl", "test"], 34 | }, 35 | () => import("@adonisjs/core/providers/vinejs_provider"), 36 | () => import("@adonisjs/cors/cors_provider"), 37 | () => import("@adonisjs/lucid/database_provider"), 38 | { 39 | file: () => import("adonisjs-scheduler/scheduler_provider"), 40 | environment: ["console"], 41 | }, 42 | ], 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Preloads 47 | |-------------------------------------------------------------------------- 48 | | 49 | | List of modules to import before starting the application. 50 | | 51 | */ 52 | preloads: [ 53 | () => import("#start/routes"), 54 | () => import("#start/kernel"), 55 | { 56 | file: () => import("#start/scheduler"), 57 | environment: ["console"], 58 | }, 59 | ], 60 | 61 | /* 62 | |-------------------------------------------------------------------------- 63 | | Tests 64 | |-------------------------------------------------------------------------- 65 | | 66 | | List of test suites to organize tests by their type. Feel free to remove 67 | | and add additional suites. 68 | | 69 | */ 70 | tests: { 71 | suites: [ 72 | { 73 | files: ["tests/unit/**/*.spec(.ts|.js)"], 74 | name: "unit", 75 | timeout: 2000, 76 | }, 77 | { 78 | files: ["tests/functional/**/*.spec(.ts|.js)"], 79 | name: "functional", 80 | timeout: 30000, 81 | }, 82 | ], 83 | forceExit: false, 84 | }, 85 | metaFiles: [ 86 | { 87 | pattern: "start.sh", 88 | reloadServer: true, 89 | }, 90 | { 91 | pattern: "swagger.*", 92 | reloadServer: true, 93 | }, 94 | ], 95 | }); 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend-topwr-sks", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "AGPL-3.0", 6 | "type": "module", 7 | "imports": { 8 | "#controllers/*": "./app/controllers/*.js", 9 | "#exceptions/*": "./app/exceptions/*.js", 10 | "#models/*": "./app/models/*.js", 11 | "#mails/*": "./app/mails/*.js", 12 | "#services/*": "./app/services/*.js", 13 | "#listeners/*": "./app/listeners/*.js", 14 | "#events/*": "./app/events/*.js", 15 | "#helpers/*": "./app/helpers/*.js", 16 | "#middleware/*": "./app/middleware/*.js", 17 | "#validators/*": "./app/validators/*.js", 18 | "#providers/*": "./providers/*.js", 19 | "#policies/*": "./app/policies/*.js", 20 | "#abilities/*": "./app/abilities/*.js", 21 | "#database/*": "./database/*.js", 22 | "#start/*": "./start/*.js", 23 | "#tests/*": "./tests/*.js", 24 | "#config/*": "./config/*.js" 25 | }, 26 | "scripts": { 27 | "build": "node ace build", 28 | "dev": "node ace serve --hmr", 29 | "format": "prettier --write .", 30 | "format:check": "prettier --check .", 31 | "lint": "eslint .", 32 | "prepare": "husky || true", 33 | "start": "node bin/server.js", 34 | "test": "node ace test", 35 | "typecheck": "tsc --noEmit" 36 | }, 37 | "lint-staged": { 38 | "*": "prettier -w --ignore-unknown" 39 | }, 40 | "prettier": "@solvro/config/prettier", 41 | "resolutions": { 42 | "strtok3": "8.0.1" 43 | }, 44 | "overrides": { 45 | "strtok3": "8.0.1" 46 | }, 47 | "dependencies": { 48 | "@adonisjs/auth": "^9.2.3", 49 | "@adonisjs/core": "^6.14.1", 50 | "@adonisjs/cors": "^2.2.1", 51 | "@adonisjs/lucid": "^21.3.0", 52 | "@solvro/solvronis-metrics": "^1.0.0", 53 | "@vinejs/vine": "^2.1.0", 54 | "adonis-autoswagger": "^3.63.0", 55 | "adonisjs-scheduler": "^1.0.5", 56 | "axios": "^1.7.7", 57 | "cheerio": "^1.0.0", 58 | "firebase-admin": "^13.4.0", 59 | "luxon": "^3.5.0", 60 | "node-fetch": "^3.3.2", 61 | "pg": "^8.13.1", 62 | "reflect-metadata": "^0.2.2", 63 | "zod": "^3.24.1" 64 | }, 65 | "devDependencies": { 66 | "@adonisjs/assembler": "^7.8.2", 67 | "@adonisjs/eslint-config": "^2.0.0-beta.6", 68 | "@adonisjs/prettier-config": "^1.4.0", 69 | "@adonisjs/tsconfig": "^1.4.0", 70 | "@commitlint/cli": "^19.6.1", 71 | "@japa/api-client": "^2.0.3", 72 | "@japa/assert": "^3.0.0", 73 | "@japa/plugin-adonisjs": "^3.0.1", 74 | "@japa/runner": "^3.1.4", 75 | "@solvro/config": "^2.0.7", 76 | "@swc/core": "1.7.26", 77 | "@types/luxon": "^3.4.2", 78 | "@types/node": "^22.7.5", 79 | "eslint": "^9.12.0", 80 | "hot-hook": "^0.3.1", 81 | "husky": "^9.1.7", 82 | "lint-staged": "^15.2.11", 83 | "nock": "^14.0.0-beta.19", 84 | "pino-pretty": "^11.2.2", 85 | "prettier": "^3.3.3", 86 | "ts-node-maintained": "^10.9.4", 87 | "typescript": "~5.6" 88 | }, 89 | "pnpm": { 90 | "overrides": { 91 | "strtok3": "8.0.1" 92 | } 93 | }, 94 | "hotHook": { 95 | "boundaries": [ 96 | "./app/controllers/**/*.ts", 97 | "./app/middleware/*.ts" 98 | ] 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/controllers/registration_tokens_controller.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import { z, null as zodNull } from "zod"; 3 | 4 | import type { HttpContext } from "@adonisjs/core/http"; 5 | 6 | import { handleError } from "#exceptions/handler"; 7 | import Device, { getTokenExpirationTime } from "#models/device"; 8 | 9 | const RegistrationTokenPayload = z.object({ 10 | deviceKey: z.string().min(1), 11 | registrationToken: z.string().min(1).or(zodNull()), 12 | }); 13 | 14 | interface RegistrationTokenInput { 15 | deviceKey: unknown; 16 | registrationToken: unknown; 17 | } 18 | 19 | const deviceKeySchema = z.string().min(1, "deviceKey param is required"); 20 | 21 | export default class RegistrationTokensController { 22 | /** 23 | * @hasToken 24 | * @summary Checks if the device has a token registered to it and if so, for how long will it be valid (in ms) 25 | * @responseBody 200 - {"currentToken":"string|null","validFor":"number|null"} 26 | * @responseBody 400 - {"error":"string"} 27 | */ 28 | async hasToken({ request, response }: HttpContext) { 29 | try { 30 | const deviceKey = deviceKeySchema.parse(request.param("deviceKey")); 31 | const device = await Device.findByOrFail("deviceKey", deviceKey); 32 | if (device.registrationToken === null) { 33 | return response 34 | .status(200) 35 | .json({ currentToken: null, validFor: null }); 36 | } 37 | const tokenTimestamp = device.tokenTimestamp?.toMillis(); 38 | const now = Date.now(); 39 | let validFor: number | null = null; 40 | if (tokenTimestamp !== undefined) { 41 | const expiration = getTokenExpirationTime(tokenTimestamp, now); 42 | if (expiration > 0) { 43 | validFor = expiration; 44 | } 45 | } 46 | return response 47 | .status(200) 48 | .json({ currentToken: device.registrationToken, validFor }); 49 | } catch (error) { 50 | return handleError(error, response); 51 | } 52 | } 53 | 54 | /** 55 | * @updateOrCreate 56 | * @summary Register or update FCM registration token. If new token is null, removes the current token 57 | * @description Stores or updates the registration token for a device. 58 | * @requestBody {"deviceKey":"string","registrationToken":"string|null"} 59 | * @responseBody 200 - {"message":"string"} 60 | * @responseBody 400 - {"message":"string","error":"string"} 61 | * @responseBody 500 - {"message":"string","error":"string"} 62 | */ 63 | async updateOrCreate({ request, response }: HttpContext) { 64 | try { 65 | const raw = request.body() as RegistrationTokenInput; 66 | 67 | const parsed = RegistrationTokenPayload.parse({ 68 | deviceKey: raw.deviceKey, 69 | registrationToken: raw.registrationToken, 70 | }); 71 | 72 | const { deviceKey, registrationToken } = parsed; 73 | const shouldRemoveToken = registrationToken === null; 74 | await Device.updateOrCreate( 75 | { deviceKey }, 76 | { 77 | registrationToken, 78 | tokenTimestamp: shouldRemoveToken ? null : DateTime.now(), 79 | }, 80 | ); 81 | 82 | return response.status(200).json({ 83 | message: `Token ${shouldRemoveToken ? "removed" : "updated"} successfully`, 84 | }); 85 | } catch (error) { 86 | return handleError(error, response); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/controllers/subscriptions_controller.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import type { HttpContext } from "@adonisjs/core/http"; 4 | import db from "@adonisjs/lucid/services/db"; 5 | 6 | import { handleError } from "#exceptions/handler"; 7 | import Device from "#models/device"; 8 | 9 | const SubscriptionToggleSchema = z.object({ 10 | deviceKey: z.string().min(1), 11 | mealId: z.number(), 12 | subscribe: z.boolean(), 13 | }); 14 | 15 | interface RawSubscriptionToggleInput { 16 | deviceKey: unknown; 17 | mealId: unknown; 18 | subscribe: unknown; 19 | } 20 | 21 | interface PgResult { 22 | rowCount: number; 23 | } 24 | 25 | const deviceKeySchema = z.string().min(1, "deviceKey param is required"); 26 | 27 | export default class SubscriptionsController { 28 | /** 29 | * @toggle 30 | * @summary Toggle subscription 31 | * @description Subscribes or unsubscribe for a meal - Get a notification if the meal is currently on the menu. 32 | * @requestBody {"deviceKey":"string","mealId":"integer","subscribe":"boolean"} 33 | * @responseBody 200 - {"message":"string"} 34 | * @responseBody 400 - {"message":"string","error":"string"} 35 | * @responseBody 500 - {"message":"string","error":"string"} 36 | */ 37 | async toggle({ request, response }: HttpContext) { 38 | try { 39 | const raw = request.body() as RawSubscriptionToggleInput; 40 | 41 | const parsed = SubscriptionToggleSchema.parse({ 42 | deviceKey: raw.deviceKey, 43 | mealId: raw.mealId, 44 | subscribe: raw.subscribe, 45 | }); 46 | const { deviceKey, mealId, subscribe } = parsed; 47 | 48 | if (subscribe) { 49 | const res: PgResult = await db.rawQuery( 50 | "INSERT INTO subscriptions (device_key, meal_id, created_at) VALUES (?, ?, NOW()) ON CONFLICT DO NOTHING", 51 | [deviceKey, mealId], 52 | { mode: "write" }, 53 | ); 54 | if (res.rowCount === 0) { 55 | return response.ok({ message: "Already subscribed" }); 56 | } else { 57 | return response.ok({ message: "Subscribed" }); 58 | } 59 | } else { 60 | const res: PgResult = await db.rawQuery( 61 | "DELETE FROM subscriptions WHERE device_key = ? AND meal_id = ?", 62 | [deviceKey, mealId], 63 | { mode: "write" }, 64 | ); 65 | if (res.rowCount === 0) { 66 | return response.ok({ message: "Was not subscribed" }); 67 | } else { 68 | return response.ok({ message: "Unsubscribed" }); 69 | } 70 | } 71 | } catch (error) { 72 | return handleError(error, response); 73 | } 74 | } 75 | 76 | /** 77 | * @current 78 | * @summary Get meals the device is subscribed to 79 | */ 80 | async listForDevice({ request, response }: HttpContext) { 81 | try { 82 | const deviceKey = deviceKeySchema.parse(request.param("deviceKey")); 83 | 84 | const device = await Device.query() 85 | .where("deviceKey", deviceKey) 86 | .preload("meals") 87 | .first(); 88 | 89 | if (device === null) { 90 | return response.ok({ 91 | subscriptions: [], 92 | }); 93 | } 94 | 95 | return response.ok({ 96 | meals: device.meals, 97 | }); 98 | } catch (error) { 99 | return handleError(error, response); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/fixtures/external_menu_response.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 107 |
108 | 109 | 110 | -------------------------------------------------------------------------------- /app/controllers/sks_users_controller.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import assert from "node:assert"; 3 | 4 | import { HttpContext } from "@adonisjs/core/http"; 5 | 6 | import SksUser from "#models/sks_user"; 7 | 8 | //this value determines the period over which the trend will be counted (multiply the value by 5 minutes) 9 | const trendDelta = 3; 10 | 11 | enum Trend { 12 | INCREASING = "INCREASING", 13 | DECREASING = "DECREASING", 14 | STABLE = "STABLE", 15 | } 16 | 17 | export default class SksUsersController { 18 | /** 19 | * @latest 20 | * @summary Display the latest SKS users record based on the current time 21 | * @description Display the latest SKS users record based on the current time 22 | * @responseBody 200 - .append("trend":"INCREASING","isResultRecent":true,"nextUpdateTimestamp": "2024-11-11T18:12:30.962+00:00") 23 | * @responseBody 404 - {"message":"Could not find the matching data in database"} 24 | * @responseBody 500 - {"message":"Failed to convert time to SQL format"} 25 | * @responseBody 500 - {"message":"Failed to fetch the latest SKS user","error": "Some error message"} 26 | */ 27 | async latest({ response }: HttpContext) { 28 | try { 29 | const currentTime = DateTime.now().setZone("Europe/Warsaw").toSQL(); 30 | if (currentTime === null) { 31 | return response 32 | .status(500) 33 | .json({ message: "Failed to convert time to SQL format" }); 34 | } 35 | 36 | const latestData = await SksUser.query() 37 | .where("externalTimestamp", "<", currentTime) 38 | .orderBy("externalTimestamp", "desc") 39 | .first(); 40 | 41 | const isResultRecent = latestData !== null && latestData.activeUsers > 0; 42 | 43 | // If the first record has activeUsers set to 0, get the second record instead 44 | const entryToReturn = isResultRecent 45 | ? latestData 46 | : await SksUser.query() 47 | .where("externalTimestamp", "<", currentTime) 48 | .orderBy("externalTimestamp", "desc") 49 | .offset(1) 50 | .first(); 51 | 52 | if (entryToReturn === null) { 53 | return response 54 | .status(404) 55 | .json({ message: "Could not find the matching data in database" }); 56 | } 57 | 58 | const referenceTime = entryToReturn.externalTimestamp.toSQL(); 59 | if (referenceTime === null) { 60 | return response.status(500).json({ 61 | message: "Failed to convert external timestamp to SQL format", 62 | }); 63 | } 64 | 65 | const trend = await this.calculateTrend( 66 | entryToReturn, 67 | referenceTime, 68 | trendDelta, 69 | ); 70 | const nextUpdateTimestamp = entryToReturn.updatedAt.plus({ 71 | minute: 5, 72 | second: 30, 73 | }); 74 | 75 | return response.status(200).json({ 76 | ...entryToReturn.toJSON(), 77 | trend, 78 | isResultRecent, 79 | nextUpdateTimestamp, 80 | }); 81 | } catch (error) { 82 | assert(error instanceof Error); 83 | return response.status(500).json({ 84 | message: "Failed to fetch the latest SKS user", 85 | error: error.message, 86 | }); 87 | } 88 | } 89 | 90 | /** 91 | * @today 92 | * @summary Display all the SKS users data from today 93 | * @description Display all the SKS users data from today 94 | * @responseBody 200 - 95 | * @responseBody 500 - {"message":"Failed to convert time to SQL format"} 96 | * @responseBody 500 - {"message":"Failed to fetch today's SKS users","error": "Some error message"} 97 | */ 98 | async today({ response }: HttpContext) { 99 | try { 100 | const currentDateTime = DateTime.now().setZone("Europe/Warsaw"); 101 | const todayStart = currentDateTime 102 | .set({ hour: 0, minute: 0, second: 0, millisecond: 0 }) 103 | .toSQL(); 104 | const todayEnd = currentDateTime 105 | .set({ hour: 23, minute: 59, second: 59, millisecond: 999 }) 106 | .toSQL(); 107 | 108 | if (todayStart === null || todayEnd === null) { 109 | return response 110 | .status(500) 111 | .json({ message: "Failed to convert time to SQL format" }); 112 | } 113 | 114 | const todayData = await SksUser.query() 115 | .whereBetween("externalTimestamp", [todayStart, todayEnd]) 116 | .orderBy("externalTimestamp", "asc"); 117 | 118 | return response.status(200).json(todayData); 119 | } catch (error) { 120 | assert(error instanceof Error); 121 | return response.status(500).json({ 122 | message: "Failed to fetch today's SKS users", 123 | error: error.message, 124 | }); 125 | } 126 | } 127 | 128 | /** 129 | * Helper function to calculate trend 130 | */ 131 | private async calculateTrend( 132 | latestData: SksUser, 133 | referenceTime: string, 134 | delta: number, 135 | ): Promise { 136 | const trendData = await SksUser.query() 137 | .where("externalTimestamp", "<", referenceTime) 138 | .orderBy("externalTimestamp", "desc") 139 | .offset(delta) 140 | .first(); 141 | 142 | if (trendData === null) { 143 | return Trend.STABLE; // If no previous data, assume stable trend 144 | } 145 | 146 | if (trendData.activeUsers < latestData.activeUsers) { 147 | return Trend.INCREASING; 148 | } else if (trendData.activeUsers > latestData.activeUsers) { 149 | return Trend.DECREASING; 150 | } else { 151 | return Trend.STABLE; 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SKS Menu Scrapper 2 | 3 | ![Solvro banner](./assets/solvro_dark.png#gh-dark-mode-only) 4 | ![Solvro banner](./assets/solvro_light.png#gh-light-mode-only) 5 | 6 | ## Description 7 | 8 | SKS Menu Scraper is a tool designed to automatically fetch and parse information about canteen menus, such as dish 9 | names, portion sizes, and prices. The project saves the scraped data into a database and provides a RESTful API for 10 | users to access menu items. 11 | 12 | The SKS Menu Scraper also includes a feature that acts as a wrapper for external API to handle and track the number of 13 | canteen users - [source](https://live.pwr.edu.pl/sks/). 14 | 15 | ## Endpoints 16 | 17 | The API is available at `sks-api.topwr.solvro.pl`. The following endpoints are available at the moment: 18 | 19 | - **GET** `/api/v1/meals` 20 | - **Description**: Retrieves list of last scraped menus. 21 | - **Request**: 22 | ```bash 23 | curl -X GET https://sks-api.topwr.solvro.pl/api/v1/meals 24 | ``` 25 | - **Params**: 26 | - page (default: 1) - the page number of records to retrieve. 27 | - limit (default: 10) - the number of records to return per page. 28 | - **Response**: 29 | ```json 30 | [ 31 | { 32 | "hash": "75e5434015551ef3d22214a4438d1961f9f57e7c0d2a460917bfd4c8dd8b7dda", 33 | "createdAt": "2024-11-15T12:10:00.545+00:00", 34 | "updatedAt": "2024-11-15T12:10:00.545+00:00", 35 | "meals": [] 36 | } 37 | ] 38 | ``` 39 | - **GET** `/api/v1/meals/current` 40 | - **Description**: Retrieves a list of all current or saved menu items (indicated by isMenuOnline field), including 41 | dish names, sizes, and prices. 42 | - **Request**: 43 | ```bash 44 | curl -X GET https://sks-api.topwr.solvro.pl/api/v1/meals/current 45 | ``` 46 | - **Response**: 47 | ```json 48 | { 49 | "meals": [ 50 | { 51 | "id": 85, 52 | "name": "Napój z soku jabłkowo-wiśniowego", 53 | "category": "DRINK", 54 | "size": "200ml", 55 | "price": "2.50", 56 | "createdAt": "2024-11-08T11:53:38.644+00:00", 57 | "updatedAt": "2024-11-08T11:53:38.644+00:00" 58 | }, 59 | { 60 | "id": 84, 61 | "name": "Ziemniaki z koperkiem", 62 | "category": "SIDE_DISH", 63 | "size": "250g", 64 | "price": "4.50", 65 | "createdAt": "2024-11-08T11:53:38.644+00:00", 66 | "updatedAt": "2024-11-08T11:53:38.644+00:00" 67 | }, 68 | { 69 | "id": 82, 70 | "name": "Pałki drobiowe w ciescie crazy", 71 | "category": "MEAT_DISH", 72 | "size": "250g", 73 | "price": "15.00", 74 | "createdAt": "2024-11-08T11:53:38.642+00:00", 75 | "updatedAt": "2024-11-08T11:53:38.642+00:00" 76 | } 77 | ], 78 | "isMenuOnline": true 79 | } 80 | ``` 81 | - **GET** `/api/v1/sks-users/current` 82 | - **Description**: Retrieves the latest SKS user data with additional info about trend, whether the data is recent or 83 | not, and timestamp for the next scheduled update. 84 | - **Request**: 85 | ```bash 86 | curl -X GET https://sks-api.topwr.solvro.pl/api/v1/sks-users/current 87 | ``` 88 | - **Response**: 89 | ```json 90 | { 91 | "activeUsers": 1, 92 | "movingAverage21": 49, 93 | "externalTimestamp": "2024-11-11T13:40:00.000+00:00", 94 | "createdAt": "2024-11-10T23:00:00.116+00:00", 95 | "updatedAt": "2024-11-11T13:42:01.017+00:00", 96 | "trend": "STABLE", 97 | "isResultRecent": true, 98 | "nextUpdateTimestamp": "2024-11-11T13:47:31.017+00:00" 99 | } 100 | ``` 101 | - **GET** `/api/v1/sks-users/today` 102 | - **Description**: Retrieves the today's data about SKS users count 103 | - **Request**: 104 | ```bash 105 | curl -X GET https://sks-api.topwr.solvro.pl/api/v1/sks-users/today 106 | ``` 107 | - **Response**: 108 | ```json 109 | [ 110 | { 111 | "activeUsers": 1, 112 | "movingAverage21": 49, 113 | "externalTimestamp": "2024-11-11T13:40:00.000+00:00", 114 | "createdAt": "2024-11-10T23:00:00.116+00:00", 115 | "updatedAt": "2024-11-11T13:42:01.017+00:00" 116 | }, 117 | "{...}" 118 | ] 119 | ``` 120 | 121 | ## Development 122 | 123 | 1. Clone the repository: 124 | 125 | ```bash 126 | git clone https://github.com/Solvro/backend-topwr-sks.git 127 | cd backend-topwr-sks 128 | ``` 129 | 130 | 2. Install the required dependencies: 131 | 132 | ```bash 133 | npm install 134 | ``` 135 | 136 | 3. Set up the PostgreSQL database: 137 | - Ensure PostgreSQL is installed and running. 138 | - Create a new database. 139 | - Update the `.env` file with your PostgreSQL credentials and database name. 140 | 141 | 4. Set up the environment variables in the `.env` file using the `.env.example` template. 142 | 143 | 5. Run migrations to create the database schema: 144 | 145 | ```bash 146 | node ace migration:run 147 | ``` 148 | 149 | 6. Run scheduler for scrapper: 150 | 151 | ```bash 152 | node ace scheduler:run 153 | # or 154 | node ace scheduler:work 155 | ``` 156 | 157 | Alternatively run scraping script once and individually: 158 | 159 | ```bash 160 | node ace scrape:menu 161 | node ace scrape:users 162 | ``` 163 | 164 | 7. Start the development server: 165 | 166 | ```bash 167 | npm run dev 168 | ``` 169 | 170 | 8. Access the data using: 171 | 172 | ```bash 173 | curl -X GET http://localhost:3333/api/v1/meals 174 | ``` 175 | 176 | ## Technologies 177 | 178 | - Node.js 179 | - Adonis.js 180 | - PostgreSQL 181 | - Coolify 182 | 183 | ## Database Schema 184 | 185 | ![schema](./assets/schema3.png) 186 | -------------------------------------------------------------------------------- /scripts/favourite_meal_notifier.ts: -------------------------------------------------------------------------------- 1 | import { applicationDefault, getApps, initializeApp } from "firebase-admin/app"; 2 | import { getMessaging } from "firebase-admin/messaging"; 3 | 4 | import logger from "@adonisjs/core/services/logger"; 5 | import db from "@adonisjs/lucid/services/db"; 6 | 7 | import Device, { TOKEN_EXPIRATION_TIME_MS } from "#models/device"; 8 | import Meal from "#models/meal"; 9 | 10 | interface FBMessage { 11 | token: string; 12 | data: { 13 | mealId: string; 14 | }; 15 | } 16 | 17 | interface FBDebugMessage { 18 | message: FBMessage; 19 | deviceKey: string; 20 | } 21 | 22 | interface FetchValidTokensQueryReturnValue { 23 | device_key: string; 24 | registration_token: string; 25 | } 26 | 27 | export async function notifyFavouriteMeal(mealId: number) { 28 | logger.info(`Processing subscriptions for meal_id=${mealId}...`); 29 | // Initialize 30 | let validTokens: FetchValidTokensQueryReturnValue[] = []; 31 | try { 32 | // Boot the database 33 | if (!Device.booted) { 34 | Device.boot(); 35 | } 36 | if (!Meal.booted) { 37 | Meal.boot(); 38 | } 39 | Device.$relationsDefinitions.forEach((relation) => { 40 | if (relation.relatedModel() === Device && !relation.booted) { 41 | relation.boot(); 42 | } 43 | }); 44 | // Expire old tokens 45 | await db.rawQuery( 46 | "UPDATE devices d SET registration_token = NULL, token_timestamp = NULL " + 47 | "FROM subscriptions s WHERE d.device_key = s.device_key " + 48 | "AND s.meal_id = ? " + 49 | "AND d.registration_token IS NOT NULL " + 50 | "AND d.token_timestamp IS NOT NULL " + 51 | "AND (EXTRACT(EPOCH FROM NOW()) - EXTRACT(EPOCH FROM d.token_timestamp)) * 1000 > ?;", 52 | [mealId, TOKEN_EXPIRATION_TIME_MS], 53 | { mode: "write" }, 54 | ); 55 | // Fetch valid tokens 56 | const queryRes: { 57 | rows: { device_key: string; registration_token: string }[]; 58 | } = await db.rawQuery( 59 | "SELECT d.device_key, d.registration_token FROM devices d " + 60 | "JOIN subscriptions s ON d.device_key = s.device_key " + 61 | "WHERE s.meal_id = ? " + 62 | "AND d.registration_token IS NOT NULL;", 63 | [mealId], 64 | { mode: "read" }, 65 | ); 66 | validTokens = queryRes.rows; 67 | } catch (error) { 68 | logger.error( 69 | "Failed to initialize database. Exiting early. Error: ", 70 | error, 71 | ); 72 | return; 73 | } 74 | if (validTokens.length === 0) { 75 | logger.info("No registration tokens found. Exiting early."); 76 | return; 77 | } 78 | logger.info(`Found ${validTokens.length} subscribed device(s)`); 79 | // Map tokens to messages 80 | const mealIdString = mealId.toString(); 81 | const validMessages: FBDebugMessage[] = validTokens.map((token) => { 82 | return { 83 | deviceKey: token.device_key, 84 | message: { 85 | token: token.registration_token, 86 | data: { 87 | mealId: mealIdString, 88 | }, 89 | }, 90 | }; 91 | }); 92 | // Active tokens 93 | const tokensToRefresh = new Set(); 94 | // Expired/Invalid tokens 95 | const tokensToDelete = new Set(); 96 | // Init FB 97 | try { 98 | if (!getApps().length) { 99 | initializeApp({ 100 | credential: applicationDefault(), 101 | }); 102 | logger.info("Firebase app initialized."); 103 | } 104 | } catch (error) { 105 | logger.error( 106 | "Failed to initialize Firebase app. Exiting early. Error: ", 107 | error, 108 | ); 109 | return; 110 | } 111 | const messagingService = getMessaging(); 112 | let successCount = 0; 113 | 114 | for (const message of validMessages) { 115 | try { 116 | await messagingService.send(message.message); 117 | tokensToRefresh.add(message.deviceKey); // Token state was refresh in FB, so it should be refreshed on our end 118 | logger.debug(`Device ${message.deviceKey}: Success`); 119 | successCount++; 120 | } catch (error) { 121 | const fbError = error as { code: string; message: string }; 122 | if (fbError.code === "messaging/registration-token-not-registered") { 123 | // Token was not expired in our DB but FB deemed it expired 124 | logger.debug( 125 | `Device ${message.deviceKey}: Failure - Token expired or not registered in FB`, 126 | ); 127 | tokensToDelete.add(message.deviceKey); 128 | } else if (fbError.code === "messaging/invalid-argument") { 129 | // Token is not valid because it is simply not - not due to expiry 130 | logger.debug( 131 | `Device ${message.deviceKey}: Failure - Token is not valid`, 132 | ); 133 | tokensToDelete.add(message.deviceKey); 134 | } else { 135 | // Error not related to the token itself 136 | logger.warn( 137 | `Device ${message.deviceKey}: Failure - unknown error ${fbError.code}. Error: ${fbError.message}`, 138 | ); 139 | } 140 | } 141 | } 142 | logger.info( 143 | `Successfully sent ${successCount} out of possible ${validMessages.length} messages.`, 144 | ); 145 | await updateTokenState(tokensToDelete, tokensToRefresh); 146 | } 147 | 148 | async function updateTokenState( 149 | tokensToDelete: Set, 150 | tokensToRefresh: Set, 151 | ) { 152 | logger.info("Updating token state in the database..."); 153 | if (tokensToRefresh.size > 0) { 154 | try { 155 | await Device.updateTokenTimestamps([...tokensToRefresh]); 156 | logger.info(`Refreshed ${tokensToRefresh.size} tokens`); 157 | } catch (error) { 158 | logger.warn(`Failed to refresh tokens: `, error); 159 | } 160 | } 161 | if (tokensToDelete.size > 0) { 162 | try { 163 | await Device.removeTokens([...tokensToDelete]); 164 | logger.info(`Removed invalid ${tokensToDelete.size} tokens`); 165 | } catch (error) { 166 | logger.warn(`Failed to remove tokens: `, error); 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /scripts/menu_scrapper.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio"; 2 | import { ElementType } from "domelementtype"; 3 | import { DateTime } from "luxon"; 4 | import assert from "node:assert"; 5 | import { createHash } from "node:crypto"; 6 | 7 | import logger from "@adonisjs/core/services/logger"; 8 | import db from "@adonisjs/lucid/services/db"; 9 | 10 | import HashesMeal from "#models/hashes_meal"; 11 | import Meal, { MealCategory } from "#models/meal"; 12 | import WebsiteHash from "#models/website_hash"; 13 | import Env from "#start/env"; 14 | 15 | import { notifyFavouriteMeal } from "./favourite_meal_notifier.js"; 16 | 17 | // this regex is barely readable 18 | // number, then optionally "g" or "ml", then optionally "/" + number + "g" or "ml", end of string 19 | const SIZE_REGEX = /\d+(?:\s?(?:g|ml))?(?:\/\d+(?:\s?(?:g|ml))?)?$/; 20 | 21 | async function getMenuHTMLOrFail() { 22 | const response = await fetch(Env.get("MENU_URL")); 23 | if (!response.ok) { 24 | throw new Error( 25 | `Failed to fetch menu data: ${response.status} - ${response.statusText}`, 26 | ); 27 | } 28 | return await response.text(); 29 | } 30 | 31 | export async function runScrapper() { 32 | const trx = await db.transaction(); 33 | try { 34 | const html = await getMenuHTMLOrFail(); 35 | // Extract hash 36 | const newHash = await getHash(html); 37 | const storedHash = await WebsiteHash.query().where("hash", newHash).first(); 38 | // Compare to existing 39 | if (storedHash !== null) { 40 | await storedHash.merge({ updatedAt: DateTime.now() }).save(); 41 | logger.info( 42 | "Hash already exists in the database. Not proceeding with scraping.", 43 | ); 44 | await trx.commit(); 45 | return; 46 | } 47 | // Create the new hash 48 | const newWebsiteHash = await WebsiteHash.create( 49 | { hash: newHash }, 50 | { client: trx }, 51 | ); 52 | // Parse the menu 53 | const meals = await parseMenu(html); 54 | // Get hashes of meals that were notified recently 55 | const recentlyNotifiedMealsSet = await getRecentHashes(); 56 | for (const meal of meals) { 57 | const mealEntity = await addMealToDb(meal.name, meal.category); 58 | if (mealEntity === null) { 59 | continue; // Failed to add, skip 60 | } 61 | // Add as hash entry 62 | await HashesMeal.create( 63 | { 64 | hashFk: newWebsiteHash.hash, 65 | mealId: mealEntity.id, 66 | size: meal.size, 67 | price: meal.price, 68 | }, 69 | { client: trx }, 70 | ); 71 | logger.debug(`${meal.name} added as ${newWebsiteHash.hash} connection.`); 72 | // Check if meal was notified recently 73 | if (!recentlyNotifiedMealsSet.has(mealEntity.id)) { 74 | // If not, notify 75 | logger.info( 76 | `Meal ${meal.name} has not been notified about recently. Sending notification...`, 77 | ); 78 | await notifyFavouriteMeal(mealEntity.id); 79 | } 80 | recentlyNotifiedMealsSet.add(mealEntity.id); 81 | } 82 | logger.info("Menu updated successfully."); 83 | await trx.commit(); 84 | } catch (error) { 85 | assert(error instanceof Error); 86 | await trx.rollback(); 87 | logger.error(`Failed to update menu: ${error.message}`, error.stack); 88 | } 89 | } 90 | 91 | /** 92 | * Gets the ids of the meals that have been notified since the current day began (that is, since 00:00:00) 93 | */ 94 | async function getRecentHashes(): Promise> { 95 | const since = DateTime.now().startOf("day"); 96 | const recentHashes = await HashesMeal.query() 97 | .select("meal_id") 98 | .where("created_at", ">", since.toJSDate()); 99 | return new Set(recentHashes.map((hash) => hash.mealId)); 100 | } 101 | 102 | export async function parseMenu(html: string) { 103 | const $ = cheerio.load(html); 104 | 105 | return $(".category") 106 | .map((_, category) => { 107 | const categoryName = $(category).find(".cat_name h2").text().trim(); 108 | const categoryEnum = assignCategories(categoryName); 109 | 110 | return $(category) 111 | .find(".pos ul li") 112 | .map((__, item) => { 113 | const itemText = item.children 114 | .find((el) => el.type === ElementType.Text) 115 | ?.data.trim() 116 | .replace(/\s+/g, " "); 117 | 118 | // failed to extract only the text field 119 | // return empty array to have .flat() skip this iteration 120 | if (itemText === undefined) { 121 | return []; 122 | } 123 | 124 | const price = $(item).find(".price").text().trim(); 125 | const priceNumeric = Number.parseFloat(price); 126 | 127 | if ( 128 | categoryEnum === MealCategory.TechnicalInfo || 129 | priceNumeric === 0 130 | ) { 131 | return { 132 | name: itemText, 133 | size: "-", 134 | price: 0, 135 | category: MealCategory.TechnicalInfo, 136 | }; 137 | } 138 | 139 | const sizeMatch = SIZE_REGEX.exec(itemText); 140 | const itemSize = 141 | sizeMatch !== null ? sizeMatch[0].trim().replace(" ", "") : "-"; 142 | const itemName = itemText.replace(SIZE_REGEX, "").trim(); 143 | 144 | return { 145 | name: itemName, 146 | size: itemSize, 147 | price: priceNumeric, 148 | category: categoryEnum, 149 | }; 150 | }) 151 | .get(); 152 | }) 153 | .get() 154 | .flat(); 155 | } 156 | 157 | export async function getHash(html: string) { 158 | return createHash("sha256").update(html).digest("hex"); 159 | } 160 | 161 | function assignCategories(category: string) { 162 | switch (category.toLowerCase()) { 163 | case "surówki": 164 | return MealCategory.Salad; 165 | case "zupy": 166 | return MealCategory.Soup; 167 | case "dania jarskie": 168 | return MealCategory.VegetarianDish; 169 | case "dania mięsne": 170 | return MealCategory.MeatDish; 171 | case "dodatki": 172 | return MealCategory.SideDish; 173 | case "desery": 174 | return MealCategory.Dessert; 175 | case "kompoty i napoje": 176 | return MealCategory.Drink; 177 | default: 178 | return MealCategory.TechnicalInfo; 179 | } 180 | } 181 | 182 | async function addMealToDb( 183 | name: string, 184 | category: MealCategory | null, 185 | ): Promise { 186 | try { 187 | let mealQuery = Meal.query().where("name", name); 188 | if (category !== null) { 189 | mealQuery = mealQuery.where("category", category); 190 | } else { 191 | mealQuery = mealQuery.whereNull("category"); 192 | } 193 | const existingMeal = await mealQuery.first(); 194 | if (existingMeal !== null) { 195 | logger.debug(`Meal ${name} already exists in the database`); 196 | return existingMeal; 197 | } else { 198 | logger.debug(`Meal ${name} does not exist in the database. Creating...`); 199 | return await Meal.create({ name, category }); 200 | } 201 | } catch (error) { 202 | assert(error instanceof Error); 203 | logger.error( 204 | `Failed to check or create meal ${name}: ${error.message}`, 205 | error.stack, 206 | ); 207 | return null; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /app/controllers/meals_controller.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import assert from "node:assert"; 3 | import { z } from "zod"; 4 | 5 | import type { HttpContext } from "@adonisjs/core/http"; 6 | import logger from "@adonisjs/core/services/logger"; 7 | import db from "@adonisjs/lucid/services/db"; 8 | 9 | import HashesMeal from "#models/hashes_meal"; 10 | import Meal from "#models/meal"; 11 | import WebsiteHash from "#models/website_hash"; 12 | 13 | const firstHashWithMealsRawSchema = z.object({ 14 | rows: z 15 | .array( 16 | z.object({ 17 | hash: z.string(), 18 | }), 19 | ) 20 | .nonempty(), 21 | }); 22 | 23 | const distinctMealIdsSchema = z.array( 24 | z.object({ 25 | meal_id: z.coerce.number(), 26 | }), 27 | ); 28 | 29 | export default class MealsController { 30 | /** 31 | * @current 32 | * @summary Get current menu items and online status 33 | * @description Retrieves the most recent menu items from the latest website scrape. If the latest scrape returned no meals, falls back to the previous scrape. 34 | * @responseBody 200 - {"meals":[{"id":"number","name":"string","category":"SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO","createdAt":"timestamp","updatedAt":"timestamp","description":"string","size":"string","price":"number"}],"isMenuOnline":"boolean","lastUpdate":"timestamp"} 35 | * @responseBody 500 - {"message":"string","error":"string"} 36 | */ 37 | async current({ response }: HttpContext) { 38 | try { 39 | const lastHash = await WebsiteHash.query() 40 | .orderBy("updatedAt", "desc") 41 | .first(); 42 | if (lastHash === null) { 43 | logger.debug("No records in the database - run scrapper"); 44 | return response 45 | .status(200) 46 | .json({ meals: [], isMenuOnline: false, lastUpdate: DateTime.now() }); 47 | } 48 | let isMenuOnline = true; 49 | let todayMeals = await getMealsByHash(lastHash.hash); 50 | logger.debug(`fetched ${todayMeals.length} meals from the database}`); 51 | 52 | if (todayMeals.length !== 0) { 53 | return response.status(200).json({ 54 | meals: getMealsDetails(todayMeals), 55 | isMenuOnline, 56 | lastUpdate: lastHash.updatedAt, 57 | }); 58 | } 59 | 60 | isMenuOnline = false; 61 | logger.debug( 62 | "No meals found in the latest hash - fetching the previous one", 63 | ); 64 | 65 | const firstHashWithMealsRaw = firstHashWithMealsRawSchema.parse( 66 | await db.rawQuery(` 67 | SELECT website_hashes.hash FROM public.website_hashes LEFT JOIN public.hashes_meals ON website_hashes.hash = hashes_meals.hash_fk 68 | GROUP BY website_hashes.hash 69 | HAVING COUNT(hashes_meals.*) != 0 70 | ORDER BY website_hashes.updated_at DESC 71 | LIMIT 1 72 | `), 73 | ).rows[0].hash; 74 | 75 | const firstHashWithMeals = await WebsiteHash.query() 76 | .where("hash", firstHashWithMealsRaw) 77 | .firstOrFail(); 78 | 79 | todayMeals = await getMealsByHash(firstHashWithMeals.hash); 80 | logger.debug(`fetched ${todayMeals.length} meals from the database}`); 81 | 82 | return response.status(200).json({ 83 | meals: getMealsDetails(todayMeals), 84 | isMenuOnline, 85 | lastUpdate: firstHashWithMeals.updatedAt, 86 | }); 87 | } catch (error) { 88 | assert(error instanceof Error); 89 | return response 90 | .status(500) 91 | .json({ message: "Failed to fetch meals", error: error.message }); 92 | } 93 | } 94 | 95 | /** 96 | * @index 97 | * @summary Get paginated historical menus 98 | * @description Retrieves a paginated list of historical menus grouped by their scrape hash. Each group includes the menu items and metadata about when the scrape occurred. 99 | * @paramQuery page - Page number for pagination - @type(integer) @minimum(1) @default(1) 100 | * @paramQuery limit - Number of records per page - @type(integer) @minimum(1) @default(10) 101 | * @responseBody 200 - [{"hash":"string","createdAt":"string","updatedAt":"string","meals":[{"id":"number","name":"string","category":"SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO","createdAt":"timestamp","updatedAt":"timestamp","description":"string","size":"string","price":"number"}]}] 102 | * @responseBody 500 - {"message":"string","error":"string"} 103 | */ 104 | async index({ request, response }: HttpContext) { 105 | try { 106 | const page = request.input("page", 1) as number; 107 | const limit = request.input("limit", 10) as number; 108 | 109 | const hashes = await HashesMeal.query() 110 | .orderBy("createdAt", "desc") 111 | .preload("websiteHash") 112 | .paginate(page, limit); 113 | 114 | const meals = await Promise.all( 115 | hashes.map(async (hash) => ({ 116 | hash: hash.hashFk, 117 | createdAt: hash.websiteHash.createdAt, 118 | updatedAt: hash.websiteHash.updatedAt, 119 | meals: await getMealsByHash(hash.hashFk).then((hashedMeals) => 120 | hashedMeals.map((singleMeal) => ({ 121 | ...singleMeal.meal.serialize(), 122 | price: singleMeal.price, 123 | size: singleMeal.size, 124 | })), 125 | ), 126 | })), 127 | ); 128 | 129 | return response.status(200).json(meals); 130 | } catch (error) { 131 | assert(error instanceof Error); 132 | return response 133 | .status(500) 134 | .json({ message: "Failed to fetch meals", error: error.message }); 135 | } 136 | } 137 | 138 | /** 139 | * @recent 140 | * @summary Get distinct meals from the last 7 days 141 | * @description Returns unique meals that appeared on the menu over the previous 7 days. Supports optional case-insensitive name filtering. 142 | * @paramQuery search - Filter results by meal name - @type(string) 143 | * @responseBody 200 - {"meals":[{"id":"number","name":"string","category":"SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO","createdAt":"timestamp","updatedAt":"timestamp"}]} 144 | * @responseBody 500 - {"message":"string","error":"string"} 145 | */ 146 | async recent({ request, response }: HttpContext) { 147 | try { 148 | const rawSearch = (request.input("search", "") as string).trim(); 149 | const sevenDaysAgo = DateTime.now().minus({ days: 7 }).toJSDate(); 150 | 151 | const mealIdRows = distinctMealIdsSchema.parse( 152 | await db 153 | .from("hashes_meals") 154 | .innerJoin( 155 | "website_hashes", 156 | "hashes_meals.hash_fk", 157 | "website_hashes.hash", 158 | ) 159 | .innerJoin("meals", "hashes_meals.meal_id", "meals.id") 160 | .where("website_hashes.updated_at", ">=", sevenDaysAgo) 161 | .if(rawSearch !== "", (query) => { 162 | void query.whereILike("meals.name", `%${rawSearch}%`); 163 | }) 164 | .select("hashes_meals.meal_id as meal_id") 165 | .distinct(), 166 | ); 167 | 168 | const mealIds = mealIdRows.map((row) => row.meal_id); 169 | 170 | if (mealIds.length === 0) { 171 | return response.status(200).json({ meals: [] }); 172 | } 173 | 174 | const meals = await Meal.query() 175 | .whereIn("id", mealIds) 176 | .orderBy("name", "asc"); 177 | 178 | return response 179 | .status(200) 180 | .json({ meals: meals.map((meal) => meal.serialize()) }); 181 | } catch (error) { 182 | assert(error instanceof Error); 183 | return response 184 | .status(500) 185 | .json({ message: "Failed to fetch meals", error: error.message }); 186 | } 187 | } 188 | } 189 | 190 | async function getMealsByHash(hash: string) { 191 | try { 192 | return await HashesMeal.query().where("hashFk", hash).preload("meal"); 193 | } catch (error) { 194 | logger.error(`Failed to fetch meals for hash ${hash}`, error); 195 | return []; 196 | } 197 | } 198 | 199 | function getMealsDetails(todayMeals: HashesMeal[]) { 200 | return todayMeals.map((singleMeal) => ({ 201 | ...singleMeal.meal.serialize(), 202 | price: singleMeal.price, 203 | size: singleMeal.size, 204 | })); 205 | } 206 | -------------------------------------------------------------------------------- /swagger.yml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | title: "SKS-Scrapper" 4 | version: "1.0.0" 5 | description: "The SKS Menu Scraper is a tool that automatically fetches and parses canteen menu data, including dish names, portion sizes, and prices, storing the information in a database. It also features a wrapper for tracking the number of canteen users, providing a comprehensive solution for managing both menu and user data through a RESTful API." 6 | components: 7 | responses: 8 | Forbidden: 9 | description: "Access token is missing or invalid" 10 | Accepted: 11 | description: "The request was accepted" 12 | Created: 13 | description: "The resource has been created" 14 | NotFound: 15 | description: "The resource has been created" 16 | NotAcceptable: 17 | description: "The resource has been created" 18 | securitySchemes: 19 | BearerAuth: 20 | type: "http" 21 | scheme: "bearer" 22 | BasicAuth: 23 | type: "http" 24 | scheme: "basic" 25 | ApiKeyAuth: 26 | type: "apiKey" 27 | in: "header" 28 | name: "X-API-Key" 29 | schemas: 30 | Any: 31 | description: "Any JSON object not defined as schema" 32 | PaginationMeta: 33 | type: "object" 34 | properties: 35 | total: 36 | type: "number" 37 | example: 100 38 | nullable: false 39 | page: 40 | type: "number" 41 | example: 2 42 | nullable: false 43 | perPage: 44 | type: "number" 45 | example: 10 46 | nullable: false 47 | currentPage: 48 | type: "number" 49 | example: 3 50 | nullable: false 51 | lastPage: 52 | type: "number" 53 | example: 10 54 | nullable: false 55 | firstPage: 56 | type: "number" 57 | example: 1 58 | nullable: false 59 | lastPageUrl: 60 | type: "string" 61 | example: "/?page=10" 62 | nullable: false 63 | firstPageUrl: 64 | type: "string" 65 | example: "/?page=1" 66 | nullable: false 67 | nextPageUrl: 68 | type: "string" 69 | example: "/?page=6" 70 | nullable: false 71 | previousPageUrl: 72 | type: "string" 73 | example: "/?page=5" 74 | nullable: false 75 | Device: 76 | type: "object" 77 | required: [] 78 | properties: 79 | device_key: 80 | type: "string" 81 | example: "Lorem Ipsum" 82 | registration_token: 83 | type: "string" 84 | example: "Lorem Ipsum" 85 | token_timestamp: 86 | $ref: "#/components/schemas/DateTime" 87 | example: null 88 | created_at: 89 | $ref: "#/components/schemas/DateTime" 90 | example: null 91 | updated_at: 92 | $ref: "#/components/schemas/DateTime" 93 | example: null 94 | meals: 95 | type: "array" 96 | items: 97 | $ref: "#/components/schemas/Meal" 98 | example: null 99 | description: "Device (Model)" 100 | HashesMeal: 101 | type: "object" 102 | required: [] 103 | properties: 104 | hash_fk: 105 | type: "string" 106 | example: "80845fe1a68deadbb4febc3f6dbae98b64a3df7a1648edd417a8ece3164182f4" 107 | meal_id: 108 | type: "number" 109 | example: 685 110 | size: 111 | type: "string" 112 | example: "200g/10g" 113 | price: 114 | type: "number" 115 | example: 21 116 | created_at: 117 | type: "string" 118 | example: "2021-03-23T16:13:08.489+01:00" 119 | format: "date-time" 120 | description: "HashesMeal (Model)" 121 | Meal: 122 | type: "object" 123 | required: [] 124 | properties: 125 | id: 126 | type: "number" 127 | example: 744 128 | name: 129 | type: "string" 130 | example: "Frytki z batatów" 131 | category: 132 | $ref: "#/components/schemas/MealCategory" 133 | example: "SALAD, SOUP, VEGETARIAN_DISH, MEAT_DISH, DESSERT, SIDE_DISH, DRINK, TECHNICAL_INFO" 134 | created_at: 135 | type: "string" 136 | example: "2021-03-23T16:13:08.489+01:00" 137 | format: "date-time" 138 | updated_at: 139 | type: "string" 140 | example: "2021-03-23T16:13:08.489+01:00" 141 | format: "date-time" 142 | hashes: 143 | type: "array" 144 | items: 145 | $ref: "#/components/schemas/HashesMeal" 146 | example: null 147 | devices: 148 | type: "array" 149 | items: 150 | $ref: "#/components/schemas/Device" 151 | example: null 152 | description: "Meal (Model)" 153 | SksUser: 154 | type: "object" 155 | required: [] 156 | properties: 157 | external_timestamp: 158 | type: "string" 159 | example: "2021-03-23T16:13:08.489+01:00" 160 | format: "date-time" 161 | active_users: 162 | type: "number" 163 | example: 21 164 | moving_average_21: 165 | type: "number" 166 | example: 37 167 | created_at: 168 | type: "string" 169 | example: "2021-03-23T16:13:08.489+01:00" 170 | format: "date-time" 171 | updated_at: 172 | type: "string" 173 | example: "2021-03-23T16:13:08.489+01:00" 174 | format: "date-time" 175 | description: "SksUser (Model)" 176 | WebsiteHash: 177 | type: "object" 178 | required: [] 179 | properties: 180 | hash: 181 | type: "string" 182 | example: "80845fe1a68deadbb4febc3f6dbae98b64a3df7a1648edd417a8ece3164182f4" 183 | created_at: 184 | type: "string" 185 | example: "2021-03-23T16:13:08.489+01:00" 186 | format: "date-time" 187 | updated_at: 188 | type: "string" 189 | example: "2021-03-23T16:13:08.489+01:00" 190 | format: "date-time" 191 | meals: 192 | type: "array" 193 | items: 194 | $ref: "#/components/schemas/HashesMeal" 195 | example: null 196 | description: "WebsiteHash (Model)" 197 | paths: 198 | /api/v1/meals: 199 | get: 200 | summary: "Get paginated historical menus (index)" 201 | description: "Returns **200** (OK) as **application/json**\n\n _app/controllers/meals_controller.ts_ - **index**" 202 | parameters: 203 | - in: "query" 204 | name: "page" 205 | description: "Page number for pagination" 206 | schema: 207 | example: "" 208 | type: "integer" 209 | required: false 210 | - in: "query" 211 | name: "limit" 212 | description: "Number of records per page" 213 | schema: 214 | example: "" 215 | type: "integer" 216 | required: false 217 | tags: 218 | - "MEALS" 219 | responses: 220 | 200: 221 | content: 222 | application/json: 223 | schema: 224 | type: "array" 225 | items: 226 | type: "object" 227 | example: 228 | - hash: "string" 229 | createdAt: "string" 230 | updatedAt: "string" 231 | meals: 232 | - id: "number" 233 | name: "string" 234 | category: "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO" 235 | createdAt: "timestamp" 236 | updatedAt: "timestamp" 237 | description: "string" 238 | size: "string" 239 | price: "number" 240 | description: "Returns **200** (OK) as **application/json**" 241 | 500: 242 | content: 243 | application/json: 244 | schema: 245 | type: "object" 246 | properties: 247 | message: "string" 248 | error: "string" 249 | example: 250 | message: "string" 251 | error: "string" 252 | description: "Returns **500** (Internal Server Error) as **application/json**" 253 | security: [] 254 | /api/v1/meals/recent: 255 | get: 256 | summary: "Get distinct meals from the last 7 days (recent)" 257 | description: "Returns **200** (OK) as **application/json**\n\n _app/controllers/meals_controller.ts_ - **recent**" 258 | parameters: 259 | - in: "query" 260 | name: "search" 261 | description: "Filter results by meal name" 262 | schema: 263 | example: "" 264 | type: "string" 265 | required: false 266 | tags: 267 | - "MEALS" 268 | responses: 269 | 200: 270 | content: 271 | application/json: 272 | schema: 273 | type: "object" 274 | properties: 275 | meals: 276 | type: "object" 277 | properties: 278 | 0: 279 | type: "object" 280 | properties: 281 | id: "number" 282 | name: "string" 283 | category: "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO" 284 | createdAt: "timestamp" 285 | updatedAt: "timestamp" 286 | example: 287 | meals: 288 | - id: "number" 289 | name: "string" 290 | category: "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO" 291 | createdAt: "timestamp" 292 | updatedAt: "timestamp" 293 | description: "Returns **200** (OK) as **application/json**" 294 | 500: 295 | content: 296 | application/json: 297 | schema: 298 | type: "object" 299 | properties: 300 | message: "string" 301 | error: "string" 302 | example: 303 | message: "string" 304 | error: "string" 305 | description: "Returns **500** (Internal Server Error) as **application/json**" 306 | security: [] 307 | /api/v1/meals/current: 308 | get: 309 | summary: "Get current menu items and online status (current)" 310 | description: "Returns **200** (OK) as **application/json**\n\n _app/controllers/meals_controller.ts_ - **current**" 311 | parameters: [] 312 | tags: 313 | - "MEALS" 314 | responses: 315 | 200: 316 | content: 317 | application/json: 318 | schema: 319 | type: "object" 320 | properties: 321 | meals: 322 | type: "object" 323 | properties: 324 | 0: 325 | type: "object" 326 | properties: 327 | id: "number" 328 | name: "string" 329 | category: "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO" 330 | createdAt: "timestamp" 331 | updatedAt: "timestamp" 332 | description: "string" 333 | size: "string" 334 | price: "number" 335 | isMenuOnline: "boolean" 336 | lastUpdate: "timestamp" 337 | example: 338 | meals: 339 | - id: "number" 340 | name: "string" 341 | category: "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO" 342 | createdAt: "timestamp" 343 | updatedAt: "timestamp" 344 | description: "string" 345 | size: "string" 346 | price: "number" 347 | isMenuOnline: "boolean" 348 | lastUpdate: "timestamp" 349 | description: "Returns **200** (OK) as **application/json**" 350 | 500: 351 | content: 352 | application/json: 353 | schema: 354 | type: "object" 355 | properties: 356 | message: "string" 357 | error: "string" 358 | example: 359 | message: "string" 360 | error: "string" 361 | description: "Returns **500** (Internal Server Error) as **application/json**" 362 | security: [] 363 | /api/v1/sks-users/current: 364 | get: 365 | summary: "Display the latest SKS users record based on the current time (latest)" 366 | description: "Returns **200** (OK) as **application/json**\n\n _app/controllers/sks_users_controller.ts_ - **latest**" 367 | parameters: [] 368 | tags: 369 | - "SKS-USERS" 370 | responses: 371 | 200: 372 | content: 373 | application/json: 374 | schema: 375 | $ref: "#/components/schemas/SksUser" 376 | example: 377 | external_timestamp: "2021-03-23T16:13:08.489+01:00" 378 | active_users: 21 379 | moving_average_21: 37 380 | created_at: "2021-03-23T16:13:08.489+01:00" 381 | updated_at: "2021-03-23T16:13:08.489+01:00" 382 | trend: "INCREASING" 383 | isResultRecent: true 384 | nextUpdateTimestamp: "2024-11-11T18:12:30.962+00:00" 385 | description: "Returns **200** (OK) as **application/json**" 386 | 404: 387 | content: 388 | application/json: 389 | schema: 390 | type: "object" 391 | properties: 392 | message: "Could not find the matching data in database" 393 | example: 394 | message: "Could not find the matching data in database" 395 | description: "Returns **404** (Not Found) as **application/json**" 396 | 500: 397 | content: 398 | application/json: 399 | schema: 400 | type: "object" 401 | properties: 402 | message: "Failed to fetch the latest SKS user" 403 | error: "Some error message" 404 | example: 405 | message: "Failed to fetch the latest SKS user" 406 | error: "Some error message" 407 | description: "Returns **500** (Internal Server Error) as **application/json**" 408 | security: [] 409 | /api/v1/sks-users/today: 410 | get: 411 | summary: "Display all the SKS users data from today (today)" 412 | description: "Returns **200** (OK) as **application/json**\n\n _app/controllers/sks_users_controller.ts_ - **today**" 413 | parameters: [] 414 | tags: 415 | - "SKS-USERS" 416 | responses: 417 | 200: 418 | content: 419 | application/json: 420 | schema: 421 | type: "array" 422 | items: 423 | $ref: "#/components/schemas/SksUser" 424 | example: 425 | - external_timestamp: "2021-03-23T16:13:08.489+01:00" 426 | active_users: 21 427 | moving_average_21: 37 428 | created_at: "2021-03-23T16:13:08.489+01:00" 429 | updated_at: "2021-03-23T16:13:08.489+01:00" 430 | description: "Returns **200** (OK) as **application/json**" 431 | 500: 432 | content: 433 | application/json: 434 | schema: 435 | type: "object" 436 | properties: 437 | message: "Failed to fetch today's SKS users" 438 | error: "Some error message" 439 | example: 440 | message: "Failed to fetch today's SKS users" 441 | error: "Some error message" 442 | description: "Returns **500** (Internal Server Error) as **application/json**" 443 | security: [] 444 | /api/v1/info: 445 | get: 446 | summary: "Get opening hours for canteen and cafe (openingHours)" 447 | description: "Returns **200** (OK) as **application/json**\n\n _app/controllers/info_controller.ts_ - **openingHours**" 448 | parameters: [] 449 | tags: 450 | - "INFO" 451 | responses: 452 | 200: 453 | content: 454 | application/json: 455 | schema: 456 | type: "object" 457 | properties: 458 | openingHours: 459 | type: "object" 460 | properties: 461 | canteen: 462 | type: "object" 463 | properties: 464 | openingTime: "string" 465 | closingTime: "string" 466 | cafe: 467 | type: "object" 468 | properties: 469 | openingTime: "string" 470 | closingTime: "string" 471 | example: 472 | openingHours: 473 | canteen: 474 | openingTime: "string" 475 | closingTime: "string" 476 | cafe: 477 | openingTime: "string" 478 | closingTime: "string" 479 | description: "Returns **200** (OK) as **application/json**" 480 | 500: 481 | content: 482 | application/json: 483 | schema: 484 | type: "object" 485 | properties: 486 | message: "string" 487 | error: "string" 488 | example: 489 | message: "string" 490 | error: "string" 491 | description: "Returns **500** (Internal Server Error) as **application/json**" 492 | security: [] 493 | /api/v1/device/registration-token: 494 | put: 495 | summary: "Register or update FCM registration token. If new token is null, removes the current token (updateOrCreate)" 496 | description: "Stores or updates the registration token for a device.\n\n _app/controllers/registration_tokens_controller.ts_ - **updateOrCreate**" 497 | parameters: [] 498 | tags: 499 | - "DEVICE" 500 | responses: 501 | 200: 502 | content: 503 | application/json: 504 | schema: 505 | type: "object" 506 | properties: 507 | message: "string" 508 | example: 509 | message: "string" 510 | description: "Returns **200** (OK) as **application/json**" 511 | 400: 512 | content: 513 | application/json: 514 | schema: 515 | type: "object" 516 | properties: 517 | message: "string" 518 | error: "string" 519 | example: 520 | message: "string" 521 | error: "string" 522 | description: "Returns **400** (Bad Request) as **application/json**" 523 | 500: 524 | content: 525 | application/json: 526 | schema: 527 | type: "object" 528 | properties: 529 | message: "string" 530 | error: "string" 531 | example: 532 | message: "string" 533 | error: "string" 534 | description: "Returns **500** (Internal Server Error) as **application/json**" 535 | security: [] 536 | requestBody: 537 | content: 538 | application/json: 539 | schema: 540 | type: "object" 541 | properties: 542 | deviceKey: "string" 543 | registrationToken: "string|null" 544 | example: 545 | deviceKey: "string" 546 | registrationToken: "string|null" 547 | /api/v1/device/registration-token/{deviceKey}: 548 | get: 549 | summary: "Checks if the device has a token registered to it and if so, for how long will it be valid (in ms) (hasToken)" 550 | description: "Returns **200** (OK) as **application/json**\n\n _app/controllers/registration_tokens_controller.ts_ - **hasToken**" 551 | parameters: 552 | - in: "path" 553 | name: "deviceKey" 554 | schema: 555 | type: "string" 556 | required: true 557 | tags: 558 | - "DEVICE" 559 | responses: 560 | 200: 561 | content: 562 | application/json: 563 | schema: 564 | type: "object" 565 | properties: 566 | currentToken: "string|null" 567 | validFor: "number|null" 568 | example: 569 | currentToken: "string|null" 570 | validFor: "number|null" 571 | description: "Returns **200** (OK) as **application/json**" 572 | 400: 573 | content: 574 | application/json: 575 | schema: 576 | type: "object" 577 | properties: 578 | error: "string" 579 | example: 580 | error: "string" 581 | description: "Returns **400** (Bad Request) as **application/json**" 582 | security: [] 583 | /api/v1/subscriptions/toggle: 584 | post: 585 | summary: "Toggle subscription (toggle)" 586 | description: "Subscribes or unsubscribe for a meal - Get a notification if the meal is currently on the menu.\n\n _app/controllers/subscriptions_controller.ts_ - **toggle**" 587 | parameters: [] 588 | tags: 589 | - "SUBSCRIPTIONS" 590 | responses: 591 | 200: 592 | content: 593 | application/json: 594 | schema: 595 | type: "object" 596 | properties: 597 | message: "string" 598 | example: 599 | message: "string" 600 | description: "Returns **200** (OK) as **application/json**" 601 | 400: 602 | content: 603 | application/json: 604 | schema: 605 | type: "object" 606 | properties: 607 | message: "string" 608 | error: "string" 609 | example: 610 | message: "string" 611 | error: "string" 612 | description: "Returns **400** (Bad Request) as **application/json**" 613 | 500: 614 | content: 615 | application/json: 616 | schema: 617 | type: "object" 618 | properties: 619 | message: "string" 620 | error: "string" 621 | example: 622 | message: "string" 623 | error: "string" 624 | description: "Returns **500** (Internal Server Error) as **application/json**" 625 | security: [] 626 | requestBody: 627 | content: 628 | application/json: 629 | schema: 630 | type: "object" 631 | properties: 632 | deviceKey: "string" 633 | mealId: "integer" 634 | subscribe: "boolean" 635 | example: 636 | deviceKey: "string" 637 | mealId: "integer" 638 | subscribe: "boolean" 639 | /api/v1/subscriptions/{deviceKey}: 640 | get: 641 | summary: " (listForDevice)" 642 | description: "\n\n _app/controllers/subscriptions_controller.ts_ - **listForDevice**" 643 | parameters: 644 | - in: "path" 645 | name: "deviceKey" 646 | schema: 647 | type: "string" 648 | required: true 649 | tags: 650 | - "SUBSCRIPTIONS" 651 | responses: 652 | 200: 653 | description: "OK" 654 | content: 655 | application/json: {} 656 | security: [] 657 | /api/v1/healthcheck: 658 | get: 659 | summary: "route" 660 | description: "\n\n __ - ****" 661 | parameters: [] 662 | tags: 663 | - "HEALTHCHECK" 664 | responses: 665 | 200: 666 | description: "OK" 667 | content: 668 | application/json: {} 669 | security: [] 670 | /metrics: 671 | get: 672 | summary: " (emitMetrics)" 673 | description: "\n\n _/Users/czaja/WebstormProjects/backend-topwr-sks/config/../app/@solvro/solvronis-metrics.ts_ - **emitMetrics**" 674 | parameters: [] 675 | tags: 676 | - "" 677 | responses: 678 | 200: 679 | description: "OK" 680 | content: 681 | application/json: {} 682 | security: [] 683 | tags: 684 | - name: "MEALS" 685 | description: "Everything related to MEALS" 686 | - name: "SKS-USERS" 687 | description: "Everything related to SKS-USERS" 688 | - name: "INFO" 689 | description: "Everything related to INFO" 690 | - name: "DEVICE" 691 | description: "Everything related to DEVICE" 692 | - name: "SUBSCRIPTIONS" 693 | description: "Everything related to SUBSCRIPTIONS" 694 | - name: "HEALTHCHECK" 695 | description: "Everything related to HEALTHCHECK" 696 | -------------------------------------------------------------------------------- /swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "SKS-Scrapper", 5 | "version": "1.0.0", 6 | "description": "The SKS Menu Scraper is a tool that automatically fetches and parses canteen menu data, including dish names, portion sizes, and prices, storing the information in a database. It also features a wrapper for tracking the number of canteen users, providing a comprehensive solution for managing both menu and user data through a RESTful API." 7 | }, 8 | "components": { 9 | "responses": { 10 | "Forbidden": { 11 | "description": "Access token is missing or invalid" 12 | }, 13 | "Accepted": { 14 | "description": "The request was accepted" 15 | }, 16 | "Created": { 17 | "description": "The resource has been created" 18 | }, 19 | "NotFound": { 20 | "description": "The resource has been created" 21 | }, 22 | "NotAcceptable": { 23 | "description": "The resource has been created" 24 | } 25 | }, 26 | "securitySchemes": { 27 | "BearerAuth": { 28 | "type": "http", 29 | "scheme": "bearer" 30 | }, 31 | "BasicAuth": { 32 | "type": "http", 33 | "scheme": "basic" 34 | }, 35 | "ApiKeyAuth": { 36 | "type": "apiKey", 37 | "in": "header", 38 | "name": "X-API-Key" 39 | } 40 | }, 41 | "schemas": { 42 | "Any": { 43 | "description": "Any JSON object not defined as schema" 44 | }, 45 | "PaginationMeta": { 46 | "type": "object", 47 | "properties": { 48 | "total": { 49 | "type": "number", 50 | "example": 100, 51 | "nullable": false 52 | }, 53 | "page": { 54 | "type": "number", 55 | "example": 2, 56 | "nullable": false 57 | }, 58 | "perPage": { 59 | "type": "number", 60 | "example": 10, 61 | "nullable": false 62 | }, 63 | "currentPage": { 64 | "type": "number", 65 | "example": 3, 66 | "nullable": false 67 | }, 68 | "lastPage": { 69 | "type": "number", 70 | "example": 10, 71 | "nullable": false 72 | }, 73 | "firstPage": { 74 | "type": "number", 75 | "example": 1, 76 | "nullable": false 77 | }, 78 | "lastPageUrl": { 79 | "type": "string", 80 | "example": "/?page=10", 81 | "nullable": false 82 | }, 83 | "firstPageUrl": { 84 | "type": "string", 85 | "example": "/?page=1", 86 | "nullable": false 87 | }, 88 | "nextPageUrl": { 89 | "type": "string", 90 | "example": "/?page=6", 91 | "nullable": false 92 | }, 93 | "previousPageUrl": { 94 | "type": "string", 95 | "example": "/?page=5", 96 | "nullable": false 97 | } 98 | } 99 | }, 100 | "Device": { 101 | "type": "object", 102 | "required": [], 103 | "properties": { 104 | "device_key": { 105 | "type": "string", 106 | "example": "Lorem Ipsum" 107 | }, 108 | "registration_token": { 109 | "type": "string", 110 | "example": "Lorem Ipsum" 111 | }, 112 | "token_timestamp": { 113 | "$ref": "#/components/schemas/DateTime", 114 | "example": null 115 | }, 116 | "created_at": { 117 | "$ref": "#/components/schemas/DateTime", 118 | "example": null 119 | }, 120 | "updated_at": { 121 | "$ref": "#/components/schemas/DateTime", 122 | "example": null 123 | }, 124 | "meals": { 125 | "type": "array", 126 | "items": { 127 | "$ref": "#/components/schemas/Meal", 128 | "example": null 129 | } 130 | } 131 | }, 132 | "description": "Device (Model)" 133 | }, 134 | "HashesMeal": { 135 | "type": "object", 136 | "required": [], 137 | "properties": { 138 | "hash_fk": { 139 | "type": "string", 140 | "example": "80845fe1a68deadbb4febc3f6dbae98b64a3df7a1648edd417a8ece3164182f4" 141 | }, 142 | "meal_id": { 143 | "type": "number", 144 | "example": 685 145 | }, 146 | "size": { 147 | "type": "string", 148 | "example": "200g/10g" 149 | }, 150 | "price": { 151 | "type": "number", 152 | "example": 21 153 | }, 154 | "created_at": { 155 | "type": "string", 156 | "example": "2021-03-23T16:13:08.489+01:00", 157 | "format": "date-time" 158 | } 159 | }, 160 | "description": "HashesMeal (Model)" 161 | }, 162 | "Meal": { 163 | "type": "object", 164 | "required": [], 165 | "properties": { 166 | "id": { 167 | "type": "number", 168 | "example": 744 169 | }, 170 | "name": { 171 | "type": "string", 172 | "example": "Frytki z batatów" 173 | }, 174 | "category": { 175 | "$ref": "#/components/schemas/MealCategory", 176 | "example": "SALAD, SOUP, VEGETARIAN_DISH, MEAT_DISH, DESSERT, SIDE_DISH, DRINK, TECHNICAL_INFO" 177 | }, 178 | "created_at": { 179 | "type": "string", 180 | "example": "2021-03-23T16:13:08.489+01:00", 181 | "format": "date-time" 182 | }, 183 | "updated_at": { 184 | "type": "string", 185 | "example": "2021-03-23T16:13:08.489+01:00", 186 | "format": "date-time" 187 | }, 188 | "hashes": { 189 | "type": "array", 190 | "items": { 191 | "$ref": "#/components/schemas/HashesMeal", 192 | "example": null 193 | } 194 | }, 195 | "devices": { 196 | "type": "array", 197 | "items": { 198 | "$ref": "#/components/schemas/Device", 199 | "example": null 200 | } 201 | } 202 | }, 203 | "description": "Meal (Model)" 204 | }, 205 | "SksUser": { 206 | "type": "object", 207 | "required": [], 208 | "properties": { 209 | "external_timestamp": { 210 | "type": "string", 211 | "example": "2021-03-23T16:13:08.489+01:00", 212 | "format": "date-time" 213 | }, 214 | "active_users": { 215 | "type": "number", 216 | "example": 21 217 | }, 218 | "moving_average_21": { 219 | "type": "number", 220 | "example": 37 221 | }, 222 | "created_at": { 223 | "type": "string", 224 | "example": "2021-03-23T16:13:08.489+01:00", 225 | "format": "date-time" 226 | }, 227 | "updated_at": { 228 | "type": "string", 229 | "example": "2021-03-23T16:13:08.489+01:00", 230 | "format": "date-time" 231 | } 232 | }, 233 | "description": "SksUser (Model)" 234 | }, 235 | "WebsiteHash": { 236 | "type": "object", 237 | "required": [], 238 | "properties": { 239 | "hash": { 240 | "type": "string", 241 | "example": "80845fe1a68deadbb4febc3f6dbae98b64a3df7a1648edd417a8ece3164182f4" 242 | }, 243 | "created_at": { 244 | "type": "string", 245 | "example": "2021-03-23T16:13:08.489+01:00", 246 | "format": "date-time" 247 | }, 248 | "updated_at": { 249 | "type": "string", 250 | "example": "2021-03-23T16:13:08.489+01:00", 251 | "format": "date-time" 252 | }, 253 | "meals": { 254 | "type": "array", 255 | "items": { 256 | "$ref": "#/components/schemas/HashesMeal", 257 | "example": null 258 | } 259 | } 260 | }, 261 | "description": "WebsiteHash (Model)" 262 | } 263 | } 264 | }, 265 | "paths": { 266 | "/api/v1/meals": { 267 | "get": { 268 | "summary": "Get paginated historical menus (index)", 269 | "description": "Returns **200** (OK) as **application/json**\n\n _app/controllers/meals_controller.ts_ - **index**", 270 | "parameters": [ 271 | { 272 | "in": "query", 273 | "name": "page", 274 | "description": "Page number for pagination", 275 | "schema": { 276 | "example": "", 277 | "type": "integer" 278 | }, 279 | "required": false 280 | }, 281 | { 282 | "in": "query", 283 | "name": "limit", 284 | "description": "Number of records per page", 285 | "schema": { 286 | "example": "", 287 | "type": "integer" 288 | }, 289 | "required": false 290 | } 291 | ], 292 | "tags": ["MEALS"], 293 | "responses": { 294 | "200": { 295 | "content": { 296 | "application/json": { 297 | "schema": { 298 | "type": "array", 299 | "items": { 300 | "type": "object" 301 | } 302 | }, 303 | "example": [ 304 | { 305 | "hash": "string", 306 | "createdAt": "string", 307 | "updatedAt": "string", 308 | "meals": [ 309 | { 310 | "id": "number", 311 | "name": "string", 312 | "category": "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO", 313 | "createdAt": "timestamp", 314 | "updatedAt": "timestamp", 315 | "description": "string", 316 | "size": "string", 317 | "price": "number" 318 | } 319 | ] 320 | } 321 | ] 322 | } 323 | }, 324 | "description": "Returns **200** (OK) as **application/json**" 325 | }, 326 | "500": { 327 | "content": { 328 | "application/json": { 329 | "schema": { 330 | "type": "object", 331 | "properties": { 332 | "message": "string", 333 | "error": "string" 334 | } 335 | }, 336 | "example": { 337 | "message": "string", 338 | "error": "string" 339 | } 340 | } 341 | }, 342 | "description": "Returns **500** (Internal Server Error) as **application/json**" 343 | } 344 | }, 345 | "security": [] 346 | } 347 | }, 348 | "/api/v1/meals/recent": { 349 | "get": { 350 | "summary": "Get distinct meals from the last 7 days (recent)", 351 | "description": "Returns **200** (OK) as **application/json**\n\n _app/controllers/meals_controller.ts_ - **recent**", 352 | "parameters": [ 353 | { 354 | "in": "query", 355 | "name": "search", 356 | "description": "Filter results by meal name", 357 | "schema": { 358 | "example": "", 359 | "type": "string" 360 | }, 361 | "required": false 362 | } 363 | ], 364 | "tags": ["MEALS"], 365 | "responses": { 366 | "200": { 367 | "content": { 368 | "application/json": { 369 | "schema": { 370 | "type": "object", 371 | "properties": { 372 | "meals": { 373 | "type": "object", 374 | "properties": { 375 | "0": { 376 | "type": "object", 377 | "properties": { 378 | "id": "number", 379 | "name": "string", 380 | "category": "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO", 381 | "createdAt": "timestamp", 382 | "updatedAt": "timestamp" 383 | } 384 | } 385 | } 386 | } 387 | } 388 | }, 389 | "example": { 390 | "meals": [ 391 | { 392 | "id": "number", 393 | "name": "string", 394 | "category": "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO", 395 | "createdAt": "timestamp", 396 | "updatedAt": "timestamp" 397 | } 398 | ] 399 | } 400 | } 401 | }, 402 | "description": "Returns **200** (OK) as **application/json**" 403 | }, 404 | "500": { 405 | "content": { 406 | "application/json": { 407 | "schema": { 408 | "type": "object", 409 | "properties": { 410 | "message": "string", 411 | "error": "string" 412 | } 413 | }, 414 | "example": { 415 | "message": "string", 416 | "error": "string" 417 | } 418 | } 419 | }, 420 | "description": "Returns **500** (Internal Server Error) as **application/json**" 421 | } 422 | }, 423 | "security": [] 424 | } 425 | }, 426 | "/api/v1/meals/current": { 427 | "get": { 428 | "summary": "Get current menu items and online status (current)", 429 | "description": "Returns **200** (OK) as **application/json**\n\n _app/controllers/meals_controller.ts_ - **current**", 430 | "parameters": [], 431 | "tags": ["MEALS"], 432 | "responses": { 433 | "200": { 434 | "content": { 435 | "application/json": { 436 | "schema": { 437 | "type": "object", 438 | "properties": { 439 | "meals": { 440 | "type": "object", 441 | "properties": { 442 | "0": { 443 | "type": "object", 444 | "properties": { 445 | "id": "number", 446 | "name": "string", 447 | "category": "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO", 448 | "createdAt": "timestamp", 449 | "updatedAt": "timestamp", 450 | "description": "string", 451 | "size": "string", 452 | "price": "number" 453 | } 454 | } 455 | } 456 | }, 457 | "isMenuOnline": "boolean", 458 | "lastUpdate": "timestamp" 459 | } 460 | }, 461 | "example": { 462 | "meals": [ 463 | { 464 | "id": "number", 465 | "name": "string", 466 | "category": "SALAD|SOUP|VEGETARIAN_DISH|MEAT_DISH|DESSERT|SIDE_DISH|DRINK|TECHNICAL_INFO", 467 | "createdAt": "timestamp", 468 | "updatedAt": "timestamp", 469 | "description": "string", 470 | "size": "string", 471 | "price": "number" 472 | } 473 | ], 474 | "isMenuOnline": "boolean", 475 | "lastUpdate": "timestamp" 476 | } 477 | } 478 | }, 479 | "description": "Returns **200** (OK) as **application/json**" 480 | }, 481 | "500": { 482 | "content": { 483 | "application/json": { 484 | "schema": { 485 | "type": "object", 486 | "properties": { 487 | "message": "string", 488 | "error": "string" 489 | } 490 | }, 491 | "example": { 492 | "message": "string", 493 | "error": "string" 494 | } 495 | } 496 | }, 497 | "description": "Returns **500** (Internal Server Error) as **application/json**" 498 | } 499 | }, 500 | "security": [] 501 | } 502 | }, 503 | "/api/v1/sks-users/current": { 504 | "get": { 505 | "summary": "Display the latest SKS users record based on the current time (latest)", 506 | "description": "Returns **200** (OK) as **application/json**\n\n _app/controllers/sks_users_controller.ts_ - **latest**", 507 | "parameters": [], 508 | "tags": ["SKS-USERS"], 509 | "responses": { 510 | "200": { 511 | "content": { 512 | "application/json": { 513 | "schema": { 514 | "$ref": "#/components/schemas/SksUser" 515 | }, 516 | "example": { 517 | "external_timestamp": "2021-03-23T16:13:08.489+01:00", 518 | "active_users": 21, 519 | "moving_average_21": 37, 520 | "created_at": "2021-03-23T16:13:08.489+01:00", 521 | "updated_at": "2021-03-23T16:13:08.489+01:00", 522 | "trend": "INCREASING", 523 | "isResultRecent": true, 524 | "nextUpdateTimestamp": "2024-11-11T18:12:30.962+00:00" 525 | } 526 | } 527 | }, 528 | "description": "Returns **200** (OK) as **application/json**" 529 | }, 530 | "404": { 531 | "content": { 532 | "application/json": { 533 | "schema": { 534 | "type": "object", 535 | "properties": { 536 | "message": "Could not find the matching data in database" 537 | } 538 | }, 539 | "example": { 540 | "message": "Could not find the matching data in database" 541 | } 542 | } 543 | }, 544 | "description": "Returns **404** (Not Found) as **application/json**" 545 | }, 546 | "500": { 547 | "content": { 548 | "application/json": { 549 | "schema": { 550 | "type": "object", 551 | "properties": { 552 | "message": "Failed to fetch the latest SKS user", 553 | "error": "Some error message" 554 | } 555 | }, 556 | "example": { 557 | "message": "Failed to fetch the latest SKS user", 558 | "error": "Some error message" 559 | } 560 | } 561 | }, 562 | "description": "Returns **500** (Internal Server Error) as **application/json**" 563 | } 564 | }, 565 | "security": [] 566 | } 567 | }, 568 | "/api/v1/sks-users/today": { 569 | "get": { 570 | "summary": "Display all the SKS users data from today (today)", 571 | "description": "Returns **200** (OK) as **application/json**\n\n _app/controllers/sks_users_controller.ts_ - **today**", 572 | "parameters": [], 573 | "tags": ["SKS-USERS"], 574 | "responses": { 575 | "200": { 576 | "content": { 577 | "application/json": { 578 | "schema": { 579 | "type": "array", 580 | "items": { 581 | "$ref": "#/components/schemas/SksUser" 582 | } 583 | }, 584 | "example": [ 585 | { 586 | "external_timestamp": "2021-03-23T16:13:08.489+01:00", 587 | "active_users": 21, 588 | "moving_average_21": 37, 589 | "created_at": "2021-03-23T16:13:08.489+01:00", 590 | "updated_at": "2021-03-23T16:13:08.489+01:00" 591 | } 592 | ] 593 | } 594 | }, 595 | "description": "Returns **200** (OK) as **application/json**" 596 | }, 597 | "500": { 598 | "content": { 599 | "application/json": { 600 | "schema": { 601 | "type": "object", 602 | "properties": { 603 | "message": "Failed to fetch today's SKS users", 604 | "error": "Some error message" 605 | } 606 | }, 607 | "example": { 608 | "message": "Failed to fetch today's SKS users", 609 | "error": "Some error message" 610 | } 611 | } 612 | }, 613 | "description": "Returns **500** (Internal Server Error) as **application/json**" 614 | } 615 | }, 616 | "security": [] 617 | } 618 | }, 619 | "/api/v1/info": { 620 | "get": { 621 | "summary": "Get opening hours for canteen and cafe (openingHours)", 622 | "description": "Returns **200** (OK) as **application/json**\n\n _app/controllers/info_controller.ts_ - **openingHours**", 623 | "parameters": [], 624 | "tags": ["INFO"], 625 | "responses": { 626 | "200": { 627 | "content": { 628 | "application/json": { 629 | "schema": { 630 | "type": "object", 631 | "properties": { 632 | "openingHours": { 633 | "type": "object", 634 | "properties": { 635 | "canteen": { 636 | "type": "object", 637 | "properties": { 638 | "openingTime": "string", 639 | "closingTime": "string" 640 | } 641 | }, 642 | "cafe": { 643 | "type": "object", 644 | "properties": { 645 | "openingTime": "string", 646 | "closingTime": "string" 647 | } 648 | } 649 | } 650 | } 651 | } 652 | }, 653 | "example": { 654 | "openingHours": { 655 | "canteen": { 656 | "openingTime": "string", 657 | "closingTime": "string" 658 | }, 659 | "cafe": { 660 | "openingTime": "string", 661 | "closingTime": "string" 662 | } 663 | } 664 | } 665 | } 666 | }, 667 | "description": "Returns **200** (OK) as **application/json**" 668 | }, 669 | "500": { 670 | "content": { 671 | "application/json": { 672 | "schema": { 673 | "type": "object", 674 | "properties": { 675 | "message": "string", 676 | "error": "string" 677 | } 678 | }, 679 | "example": { 680 | "message": "string", 681 | "error": "string" 682 | } 683 | } 684 | }, 685 | "description": "Returns **500** (Internal Server Error) as **application/json**" 686 | } 687 | }, 688 | "security": [] 689 | } 690 | }, 691 | "/api/v1/device/registration-token": { 692 | "put": { 693 | "summary": "Register or update FCM registration token. If new token is null, removes the current token (updateOrCreate)", 694 | "description": "Stores or updates the registration token for a device.\n\n _app/controllers/registration_tokens_controller.ts_ - **updateOrCreate**", 695 | "parameters": [], 696 | "tags": ["DEVICE"], 697 | "responses": { 698 | "200": { 699 | "content": { 700 | "application/json": { 701 | "schema": { 702 | "type": "object", 703 | "properties": { 704 | "message": "string" 705 | } 706 | }, 707 | "example": { 708 | "message": "string" 709 | } 710 | } 711 | }, 712 | "description": "Returns **200** (OK) as **application/json**" 713 | }, 714 | "400": { 715 | "content": { 716 | "application/json": { 717 | "schema": { 718 | "type": "object", 719 | "properties": { 720 | "message": "string", 721 | "error": "string" 722 | } 723 | }, 724 | "example": { 725 | "message": "string", 726 | "error": "string" 727 | } 728 | } 729 | }, 730 | "description": "Returns **400** (Bad Request) as **application/json**" 731 | }, 732 | "500": { 733 | "content": { 734 | "application/json": { 735 | "schema": { 736 | "type": "object", 737 | "properties": { 738 | "message": "string", 739 | "error": "string" 740 | } 741 | }, 742 | "example": { 743 | "message": "string", 744 | "error": "string" 745 | } 746 | } 747 | }, 748 | "description": "Returns **500** (Internal Server Error) as **application/json**" 749 | } 750 | }, 751 | "security": [], 752 | "requestBody": { 753 | "content": { 754 | "application/json": { 755 | "schema": { 756 | "type": "object", 757 | "properties": { 758 | "deviceKey": "string", 759 | "registrationToken": "string|null" 760 | } 761 | }, 762 | "example": { 763 | "deviceKey": "string", 764 | "registrationToken": "string|null" 765 | } 766 | } 767 | } 768 | } 769 | } 770 | }, 771 | "/api/v1/device/registration-token/{deviceKey}": { 772 | "get": { 773 | "summary": "Checks if the device has a token registered to it and if so, for how long will it be valid (in ms) (hasToken)", 774 | "description": "Returns **200** (OK) as **application/json**\n\n _app/controllers/registration_tokens_controller.ts_ - **hasToken**", 775 | "parameters": [ 776 | { 777 | "in": "path", 778 | "name": "deviceKey", 779 | "schema": { 780 | "type": "string" 781 | }, 782 | "required": true 783 | } 784 | ], 785 | "tags": ["DEVICE"], 786 | "responses": { 787 | "200": { 788 | "content": { 789 | "application/json": { 790 | "schema": { 791 | "type": "object", 792 | "properties": { 793 | "currentToken": "string|null", 794 | "validFor": "number|null" 795 | } 796 | }, 797 | "example": { 798 | "currentToken": "string|null", 799 | "validFor": "number|null" 800 | } 801 | } 802 | }, 803 | "description": "Returns **200** (OK) as **application/json**" 804 | }, 805 | "400": { 806 | "content": { 807 | "application/json": { 808 | "schema": { 809 | "type": "object", 810 | "properties": { 811 | "error": "string" 812 | } 813 | }, 814 | "example": { 815 | "error": "string" 816 | } 817 | } 818 | }, 819 | "description": "Returns **400** (Bad Request) as **application/json**" 820 | } 821 | }, 822 | "security": [] 823 | } 824 | }, 825 | "/api/v1/subscriptions/toggle": { 826 | "post": { 827 | "summary": "Toggle subscription (toggle)", 828 | "description": "Subscribes or unsubscribe for a meal - Get a notification if the meal is currently on the menu.\n\n _app/controllers/subscriptions_controller.ts_ - **toggle**", 829 | "parameters": [], 830 | "tags": ["SUBSCRIPTIONS"], 831 | "responses": { 832 | "200": { 833 | "content": { 834 | "application/json": { 835 | "schema": { 836 | "type": "object", 837 | "properties": { 838 | "message": "string" 839 | } 840 | }, 841 | "example": { 842 | "message": "string" 843 | } 844 | } 845 | }, 846 | "description": "Returns **200** (OK) as **application/json**" 847 | }, 848 | "400": { 849 | "content": { 850 | "application/json": { 851 | "schema": { 852 | "type": "object", 853 | "properties": { 854 | "message": "string", 855 | "error": "string" 856 | } 857 | }, 858 | "example": { 859 | "message": "string", 860 | "error": "string" 861 | } 862 | } 863 | }, 864 | "description": "Returns **400** (Bad Request) as **application/json**" 865 | }, 866 | "500": { 867 | "content": { 868 | "application/json": { 869 | "schema": { 870 | "type": "object", 871 | "properties": { 872 | "message": "string", 873 | "error": "string" 874 | } 875 | }, 876 | "example": { 877 | "message": "string", 878 | "error": "string" 879 | } 880 | } 881 | }, 882 | "description": "Returns **500** (Internal Server Error) as **application/json**" 883 | } 884 | }, 885 | "security": [], 886 | "requestBody": { 887 | "content": { 888 | "application/json": { 889 | "schema": { 890 | "type": "object", 891 | "properties": { 892 | "deviceKey": "string", 893 | "mealId": "integer", 894 | "subscribe": "boolean" 895 | } 896 | }, 897 | "example": { 898 | "deviceKey": "string", 899 | "mealId": "integer", 900 | "subscribe": "boolean" 901 | } 902 | } 903 | } 904 | } 905 | } 906 | }, 907 | "/api/v1/subscriptions/{deviceKey}": { 908 | "get": { 909 | "summary": " (listForDevice)", 910 | "description": "\n\n _app/controllers/subscriptions_controller.ts_ - **listForDevice**", 911 | "parameters": [ 912 | { 913 | "in": "path", 914 | "name": "deviceKey", 915 | "schema": { 916 | "type": "string" 917 | }, 918 | "required": true 919 | } 920 | ], 921 | "tags": ["SUBSCRIPTIONS"], 922 | "responses": { 923 | "200": { 924 | "description": "OK", 925 | "content": { 926 | "application/json": {} 927 | } 928 | } 929 | }, 930 | "security": [] 931 | } 932 | }, 933 | "/api/v1/healthcheck": { 934 | "get": { 935 | "summary": "route", 936 | "description": "\n\n __ - ****", 937 | "parameters": [], 938 | "tags": ["HEALTHCHECK"], 939 | "responses": { 940 | "200": { 941 | "description": "OK", 942 | "content": { 943 | "application/json": {} 944 | } 945 | } 946 | }, 947 | "security": [] 948 | } 949 | }, 950 | "/metrics": { 951 | "get": { 952 | "summary": " (emitMetrics)", 953 | "description": "\n\n _/Users/czaja/WebstormProjects/backend-topwr-sks/config/../app/@solvro/solvronis-metrics.ts_ - **emitMetrics**", 954 | "parameters": [], 955 | "tags": [""], 956 | "responses": { 957 | "200": { 958 | "description": "OK", 959 | "content": { 960 | "application/json": {} 961 | } 962 | } 963 | }, 964 | "security": [] 965 | } 966 | } 967 | }, 968 | "tags": [ 969 | { 970 | "name": "MEALS", 971 | "description": "Everything related to MEALS" 972 | }, 973 | { 974 | "name": "SKS-USERS", 975 | "description": "Everything related to SKS-USERS" 976 | }, 977 | { 978 | "name": "INFO", 979 | "description": "Everything related to INFO" 980 | }, 981 | { 982 | "name": "DEVICE", 983 | "description": "Everything related to DEVICE" 984 | }, 985 | { 986 | "name": "SUBSCRIPTIONS", 987 | "description": "Everything related to SUBSCRIPTIONS" 988 | }, 989 | { 990 | "name": "HEALTHCHECK", 991 | "description": "Everything related to HEALTHCHECK" 992 | } 993 | ] 994 | } 995 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------