├── client ├── src │ ├── assets │ │ ├── .gitkeep │ │ ├── auth.png │ │ ├── og-image.jpg │ │ ├── schedule.png │ │ ├── google-login.png │ │ ├── select-term.png │ │ ├── sync-schedule.png │ │ ├── icons │ │ │ ├── icon-72x72.png │ │ │ ├── icon-96x96.png │ │ │ ├── icon-128x128.png │ │ │ ├── icon-144x144.png │ │ │ ├── icon-152x152.png │ │ │ ├── icon-192x192.png │ │ │ ├── icon-384x384.png │ │ │ └── icon-512x512.png │ │ ├── schedule-details.png │ │ └── google_g_icon.svg │ ├── app │ │ ├── shared │ │ │ ├── export-calendar │ │ │ │ ├── export-calendar.component.scss │ │ │ │ ├── export-calendar.component.html │ │ │ │ ├── components │ │ │ │ │ ├── calendar-options │ │ │ │ │ │ ├── calendar-options.component.scss │ │ │ │ │ │ ├── calendar-options.component.spec.ts │ │ │ │ │ │ ├── calendar-options.component.html │ │ │ │ │ │ └── calendar-options.component.ts │ │ │ │ │ └── subjects-selector │ │ │ │ │ │ ├── subjects-selector.component.spec.ts │ │ │ │ │ │ ├── subjects-selector.component.scss │ │ │ │ │ │ ├── subjects-selector.component.html │ │ │ │ │ │ └── subjects-selector.component.ts │ │ │ │ ├── export-calendar.component.ts │ │ │ │ └── export-calendar.component.spec.ts │ │ │ ├── dialogs │ │ │ │ ├── confirmation-dialog │ │ │ │ │ ├── confirmation-dialog.component.scss │ │ │ │ │ ├── confirmation-dialog.component.html │ │ │ │ │ ├── confirmation-dialog.component.ts │ │ │ │ │ └── confirmation-dialog.component.spec.ts │ │ │ │ └── subject-details-dialog │ │ │ │ │ ├── subject-details-dialog.component.scss │ │ │ │ │ ├── subject-details-dialog.component.spec.ts │ │ │ │ │ ├── subject-details-dialog.component.html │ │ │ │ │ └── subject-details-dialog.component.ts │ │ │ ├── period-selector │ │ │ │ ├── period-selector.component.scss │ │ │ │ ├── period-selector.component.spec.ts │ │ │ │ ├── period-selector.component.html │ │ │ │ └── period-selector.component.ts │ │ │ └── schedule │ │ │ │ ├── schedule.component.spec.ts │ │ │ │ ├── schedule.component.scss │ │ │ │ ├── schedule.component.html │ │ │ │ └── schedule.component.ts │ │ ├── models │ │ │ ├── dialog-data.model.ts │ │ │ ├── event-color.model.ts │ │ │ ├── term.model.ts │ │ │ ├── subject-details-data.model.ts │ │ │ └── subject.model.ts │ │ ├── app.component.ts │ │ ├── constants │ │ │ └── index.ts │ │ ├── components │ │ │ ├── privacy-policy │ │ │ │ ├── privacy-policy.component.scss │ │ │ │ ├── privacy-policy.component.ts │ │ │ │ ├── privacy-policy.component.spec.ts │ │ │ │ └── privacy-policy.component.html │ │ │ ├── help │ │ │ │ ├── help.component.ts │ │ │ │ ├── help.component.scss │ │ │ │ ├── help.component.spec.ts │ │ │ │ └── help.component.html │ │ │ ├── home │ │ │ │ ├── services │ │ │ │ │ ├── user.service.spec.ts │ │ │ │ │ ├── google-calendar.service.spec.ts │ │ │ │ │ ├── google-calendar.service.ts │ │ │ │ │ └── user.service.ts │ │ │ │ ├── home.component.spec.ts │ │ │ │ ├── home.component.scss │ │ │ │ ├── home.component.html │ │ │ │ ├── home.component.ts │ │ │ │ ├── home-routing.module.ts │ │ │ │ └── home.module.ts │ │ │ ├── login │ │ │ │ ├── login.component.scss │ │ │ │ ├── login.component.spec.ts │ │ │ │ ├── login.component.ts │ │ │ │ └── login.component.html │ │ │ └── index │ │ │ │ ├── index.component.spec.ts │ │ │ │ ├── index.component.ts │ │ │ │ ├── index.component.scss │ │ │ │ └── index.component.html │ │ ├── services │ │ │ ├── auth.service.spec.ts │ │ │ ├── notification.service.spec.ts │ │ │ ├── statistics.service.spec.ts │ │ │ ├── statistics.service.ts │ │ │ ├── notification.service.ts │ │ │ └── auth.service.ts │ │ ├── guards │ │ │ ├── auth.guard.spec.ts │ │ │ ├── schedule.guard.spec.ts │ │ │ ├── google-oauth.guard.spec.ts │ │ │ ├── auth.guard.ts │ │ │ ├── schedule.guard.ts │ │ │ └── google-oauth.guard.ts │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app-routing.module.ts │ │ ├── app.component.spec.ts │ │ ├── interceptors │ │ │ └── auth.interceptor.ts │ │ └── app.module.ts │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon-180x180.png │ ├── sitemap.xml │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── main.ts │ ├── test.ts │ ├── theme.scss │ ├── manifest.webmanifest │ ├── index.html │ ├── polyfills.ts │ └── styles.scss ├── bun.lockb ├── .firebaserc ├── sitemap.xml ├── e2e │ ├── tsconfig.json │ ├── src │ │ ├── app.po.ts │ │ └── app.e2e-spec.ts │ └── protractor.conf.js ├── tsconfig.app.json ├── .editorconfig ├── tsconfig.spec.json ├── browserslist ├── tsconfig.json ├── replace.build.js ├── ngsw-config.json ├── firebase.json ├── .gitignore ├── karma.conf.js ├── package.json ├── tslint.json └── angular.json ├── server ├── .gitignore ├── bun.lockb ├── handler.js ├── tests │ ├── setup.js │ ├── services │ │ └── firebase.test.js │ └── models │ │ └── googleCalendar.test.js ├── .env.example ├── src │ ├── api │ │ └── v1 │ │ │ ├── statistics │ │ │ ├── routes.js │ │ │ └── controller.js │ │ │ ├── users │ │ │ ├── routes.js │ │ │ ├── controller.js │ │ │ └── model.js │ │ │ ├── googleCalendar │ │ │ ├── routes.js │ │ │ ├── controller.js │ │ │ └── model.js │ │ │ └── index.js │ ├── utils │ │ └── logger.js │ ├── lib │ │ ├── ApiError.js │ │ └── Date.js │ ├── services │ │ ├── firebase.js │ │ ├── auth.js │ │ ├── pomelo.js │ │ └── calendar.js │ ├── index.js │ └── config │ │ └── index.js ├── .eslintrc.json ├── serverless.yml └── package.json ├── .firebaserc ├── .gitignore ├── firebase.json ├── .github ├── FUNDING.yml ├── workflows │ ├── server-pr-checks.yml │ ├── server.yml │ └── client.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── README.md ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md /client/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | coverage 3 | .serverless -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/export-calendar.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "mihorarioun" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/bun.lockb -------------------------------------------------------------------------------- /server/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/server/bun.lockb -------------------------------------------------------------------------------- /client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/favicon.ico -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/export-calendar.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/auth.png -------------------------------------------------------------------------------- /client/src/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/favicon-16x16.png -------------------------------------------------------------------------------- /client/src/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/favicon-32x32.png -------------------------------------------------------------------------------- /client/src/assets/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/og-image.jpg -------------------------------------------------------------------------------- /client/src/assets/schedule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/schedule.png -------------------------------------------------------------------------------- /client/src/assets/google-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/google-login.png -------------------------------------------------------------------------------- /client/src/assets/select-term.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/select-term.png -------------------------------------------------------------------------------- /client/src/app/models/dialog-data.model.ts: -------------------------------------------------------------------------------- 1 | export interface DialogData { 2 | title: string; 3 | message: string; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/assets/sync-schedule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/sync-schedule.png -------------------------------------------------------------------------------- /client/src/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /client/src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /client/src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /client/src/assets/schedule-details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/schedule-details.png -------------------------------------------------------------------------------- /client/src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /client/src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /client/src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /client/src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /client/src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /client/src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjdonado/mihorario/HEAD/client/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /client/src/app/models/event-color.model.ts: -------------------------------------------------------------------------------- 1 | export interface EventColor { 2 | id: number; 3 | background: string; 4 | foreground: string; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/app/models/term.model.ts: -------------------------------------------------------------------------------- 1 | export interface Term { 2 | id: string; 3 | name: string; 4 | startDate: string; 5 | endDate: string; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/app/shared/dialogs/confirmation-dialog/confirmation-dialog.component.scss: -------------------------------------------------------------------------------- 1 | h1 { 2 | text-transform: uppercase; 3 | letter-spacing: 0.1em; 4 | } -------------------------------------------------------------------------------- /server/handler.js: -------------------------------------------------------------------------------- 1 | const serverless = require('serverless-http'); 2 | 3 | const app = require('./src'); 4 | 5 | module.exports.handler = serverless(app); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .env 4 | .vscode 5 | *.zip 6 | mihorarioUN.pem 7 | server/yarn.lock 8 | credentials.json 9 | docker-compose.yml 10 | .firebase -------------------------------------------------------------------------------- /client/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "targets": { 3 | "mihorarioun": { 4 | "hosting": { 5 | "ui": [ 6 | "mihorarioun" 7 | ] 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /server/tests/setup.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const MockAdapter = require('axios-mock-adapter'); 3 | 4 | const axiosMock = new MockAdapter(axios); 5 | 6 | global.axiosMock = axiosMock; 7 | -------------------------------------------------------------------------------- /client/src/app/models/subject-details-data.model.ts: -------------------------------------------------------------------------------- 1 | import { EventColor } from './event-color.model'; 2 | 3 | export interface SubjectDetailsData { 4 | color: EventColor; 5 | notificationTime: number; 6 | } 7 | -------------------------------------------------------------------------------- /client/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://mihorarioun.web.app/ 5 | 6 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | CLIENT_NAME=http://localhost:4200 3 | GOOGLE_CALENDAR_CALLBACK=http://localhost:3000 4 | POMELO_BASE_URL= 5 | GOOGLE_SECRET_CLIENT= 6 | GOOGLE_CLIENT_ID= 7 | SECRET= 8 | FIREBASE_PRIVATE_KEY= -------------------------------------------------------------------------------- /client/src/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://mihorarioun.web.app/ 5 | 6 | -------------------------------------------------------------------------------- /server/src/api/v1/statistics/routes.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | 3 | const controller = require('./controller'); 4 | 5 | router.get('/users', controller.countAllUsers); 6 | 7 | module.exports = router; 8 | -------------------------------------------------------------------------------- /client/src/app/shared/period-selector/period-selector.component.scss: -------------------------------------------------------------------------------- 1 | mat-form-field { 2 | width: 250px; 3 | } 4 | 5 | .select-title { 6 | font-size: 16px; 7 | } 8 | 9 | mat-select, mat-option, mat-label { 10 | letter-spacing: 0.1em; 11 | } -------------------------------------------------------------------------------- /client/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.scss'] 7 | }) 8 | export class AppComponent { 9 | title = 'Mi horario UN'; 10 | } 11 | -------------------------------------------------------------------------------- /client/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts", 9 | "src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const USER_TOKEN_COOKIE = 'user-token'; 2 | export const POMELO_DATA_COOKIE = 'pomelo-data'; 3 | export const SCHEDULE_BY_HOURS_KEY = 'subjects-by-hours'; 4 | export const SUBJECTS_BY_DAYS_KEY = 'subjects-by-days'; 5 | export const GOOGLE_OAUTH_DATA_KEY = 'google-oauth'; 6 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /client/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText() { 9 | return element(by.css('app-root h1')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "client/dist/client", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/app/components/privacy-policy/privacy-policy.component.scss: -------------------------------------------------------------------------------- 1 | mat-card { 2 | max-width: 90%; 3 | } 4 | 5 | .container { 6 | width: 100%; 7 | } 8 | 9 | .info { 10 | max-width: 100%; 11 | overflow-x: hidden; 12 | p { 13 | letter-spacing: 0.1em; 14 | text-indent: 0; 15 | line-height: 15pt; 16 | } 17 | } -------------------------------------------------------------------------------- /server/src/api/v1/users/routes.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | 3 | const controller = require('./controller'); 4 | const { auth } = require('../../../services/auth'); 5 | 6 | router.get('/schedule', auth, controller.getSchedule); 7 | 8 | router.post('/login', controller.login); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /server/src/utils/logger.js: -------------------------------------------------------------------------------- 1 | const pino = require('pino'); 2 | const moment = require('moment'); 3 | 4 | const logger = pino({ 5 | level: process.env.PINO_LEVEL, 6 | prettyPrint: true, 7 | messageKey: 'message', 8 | timestamp: () => `,"time":"${moment().format('YYYY-MM-DD HH:mm:ss')}"`, 9 | base: {}, 10 | }); 11 | 12 | module.exports = logger; 13 | -------------------------------------------------------------------------------- /client/src/app/components/help/help.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-help', 5 | templateUrl: './help.component.html', 6 | styleUrls: ['./help.component.scss'] 7 | }) 8 | export class HelpComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit() { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /client/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/components/calendar-options/calendar-options.component.scss: -------------------------------------------------------------------------------- 1 | .logo-wrapper { 2 | padding-left: 10px; 3 | } 4 | 5 | .logo { 6 | background-image: url("/assets/google_g_icon.svg"); 7 | width:18px; 8 | height:18px; 9 | } 10 | 11 | .options-title { 12 | text-transform: uppercase; 13 | } 14 | 15 | button { 16 | min-width: 10px !important; 17 | } -------------------------------------------------------------------------------- /server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": "airbnb-base", 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2018 15 | }, 16 | "rules": { 17 | "no-unused-vars": 0 18 | } 19 | } -------------------------------------------------------------------------------- /server/src/api/v1/statistics/controller.js: -------------------------------------------------------------------------------- 1 | const { listAllUsers } = require('../../../services/firebase'); 2 | 3 | const countAllUsers = async (req, res, next) => { 4 | try { 5 | const totalUsersCounter = await listAllUsers(); 6 | res.json({ data: { totalUsersCounter } }); 7 | } catch (err) { 8 | next(err); 9 | } 10 | }; 11 | 12 | module.exports = { 13 | countAllUsers, 14 | }; 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [krthr] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # custom: # Replace with a single custom sponsorship URL 9 | -------------------------------------------------------------------------------- /client/src/app/services/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { AuthService } from './auth.service'; 4 | 5 | describe('AuthService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: AuthService = TestBed.get(AuthService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/app/components/privacy-policy/privacy-policy.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-privacy-policy', 5 | templateUrl: './privacy-policy.component.html', 6 | styleUrls: ['./privacy-policy.component.scss'] 7 | }) 8 | export class PrivacyPolicyComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit() {} 13 | 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/components/home/services/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { UserService } from './user.service'; 4 | 5 | describe('UserService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: UserService = TestBed.get(UserService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /server/src/api/v1/googleCalendar/routes.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | 3 | const controller = require('./controller'); 4 | const { auth } = require('../../../services/auth'); 5 | 6 | router.post('/sync', auth, controller.syncSchedule); 7 | router.post('/import', auth, controller.importSubjectsToCalendar); 8 | router.post('/remove', auth, controller.removeSubjectsFromCalendar); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /server/src/api/v1/index.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express'); 2 | 3 | const users = require('./users/routes'); 4 | const googleCalendar = require('./googleCalendar/routes'); 5 | const statistics = require('./statistics/routes'); 6 | 7 | const router = Router(); 8 | 9 | router.use('/users', users); 10 | router.use('/google-calendar', googleCalendar); 11 | router.use('/statistics', statistics); 12 | 13 | module.exports = router; 14 | -------------------------------------------------------------------------------- /client/src/app/shared/dialogs/confirmation-dialog/confirmation-dialog.component.html: -------------------------------------------------------------------------------- 1 |

{{ data.title }}

2 |
3 |

{{ data.message }}

4 |
5 |
6 | 7 | 8 |
9 | -------------------------------------------------------------------------------- /.github/workflows/server-pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: PR checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Build 16 | run: npm --prefix server install 17 | - name: Run tests 18 | run: npm --prefix server run test 19 | -------------------------------------------------------------------------------- /client/src/app/guards/auth.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async, inject } from '@angular/core/testing'; 2 | 3 | import { AuthGuard } from './auth.guard'; 4 | 5 | describe('AuthGuard', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [AuthGuard] 9 | }); 10 | }); 11 | 12 | it('should ...', inject([AuthGuard], (guard: AuthGuard) => { 13 | expect(guard).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /client/src/app/components/help/help.component.scss: -------------------------------------------------------------------------------- 1 | mat-card { 2 | max-width: 90%; 3 | max-height: 90%; 4 | } 5 | 6 | .info { 7 | overflow-x: auto; 8 | padding: 16px; 9 | text-align: center; 10 | p { 11 | text-align: left; 12 | letter-spacing: 0.1em; 13 | text-indent: 0; 14 | line-height: 15pt; 15 | } 16 | img { 17 | margin-bottom: 20px; 18 | width: 90%; 19 | min-width: 240px; 20 | max-width: 720px; 21 | } 22 | } -------------------------------------------------------------------------------- /client/src/app/services/notification.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { NotificationService } from './notification.service'; 4 | 5 | describe('NotificationService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: NotificationService = TestBed.get(NotificationService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/app/guards/schedule.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async, inject } from '@angular/core/testing'; 2 | 3 | import { ScheduleGuard } from './schedule.guard'; 4 | 5 | describe('ScheduleGuard', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [ScheduleGuard] 9 | }); 10 | }); 11 | 12 | it('should ...', inject([ScheduleGuard], (guard: ScheduleGuard) => { 13 | expect(guard).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /client/browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /client/src/app/components/home/services/google-calendar.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { GoogleCalendarService } from './google-calendar.service'; 4 | 5 | describe('GoogleCalendarService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: GoogleCalendarService = TestBed.get(GoogleCalendarService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/app/services/statistics.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { StatisticsService } from './statistics.service'; 4 | 5 | describe('StatisticsService', () => { 6 | let service: StatisticsService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(StatisticsService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /client/src/app/guards/google-oauth.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async, inject } from '@angular/core/testing'; 2 | 3 | import { GoogleOauthGuard } from './google-oauth.guard'; 4 | 5 | describe('GoogleOauthGuard', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [GoogleOauthGuard] 9 | }); 10 | }); 11 | 12 | it('should ...', inject([GoogleOauthGuard], (guard: GoogleOauthGuard) => { 13 | expect(guard).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /client/src/app/models/subject.model.ts: -------------------------------------------------------------------------------- 1 | import { EventColor } from './event-color.model'; 2 | 3 | export interface Subject { 4 | nrc: string; 5 | name: string; 6 | shortName: string; 7 | instructors: string; 8 | place: string; 9 | type: string; 10 | startTime: string; 11 | endTime: string; 12 | startDate: Date; 13 | endDate: Date; 14 | firstMeetingDate: Date; 15 | lastMeetingDate: Date; 16 | color?: EventColor; 17 | notificationTime?: number; 18 | googleSynced?: boolean; 19 | } 20 | -------------------------------------------------------------------------------- /client/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | cookies: { 4 | expires: 1, 5 | path: '/', 6 | domain: 'mihorarioun.web.app', 7 | secure: true, 8 | }, 9 | apiUrl: '{LAMBDA_API_URL}', 10 | firebase: { 11 | apiKey: '{FIREBASE_API_KEY}', 12 | authDomain: 'mihorarioun.firebaseapp.com', 13 | databaseURL: '', 14 | projectId: 'mihorarioun', 15 | storageBucket: '', 16 | messagingSenderId: '119902822900' 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | if (window) { 10 | window.console.log = () => {}; 11 | window.console.warn = () => {}; 12 | } 13 | } 14 | 15 | platformBrowserDynamic().bootstrapModule(AppModule) 16 | .catch(err => console.error(err)); 17 | -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/export-calendar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { AuthService } from 'src/app/services/auth.service'; 3 | 4 | @Component({ 5 | selector: 'app-export-calendar', 6 | templateUrl: './export-calendar.component.html', 7 | styleUrls: ['./export-calendar.component.scss'] 8 | }) 9 | export class ExportCalendarComponent implements OnInit { 10 | 11 | constructor( 12 | private authService: AuthService, 13 | ) { } 14 | 15 | ngOnInit() { 16 | 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2018", 19 | "dom" 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, Router } from '@angular/router'; 3 | import { AuthService } from '../services/auth.service'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class AuthGuard implements CanActivate { 9 | constructor( 10 | private router: Router, 11 | private authService: AuthService 12 | ) { } 13 | 14 | canActivate() { 15 | if (this.authService.token) { 16 | return true; 17 | } 18 | this.router.navigateByUrl('/login'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/src/lib/ApiError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ApiError 3 | * @author sjdonado 4 | * @since 1.0.0 5 | */ 6 | 7 | class ApiError extends Error { 8 | /** 9 | * ApiError constructor 10 | * @param {String} message 11 | * @param {Number} statusCode 12 | */ 13 | constructor(message, statusCode) { 14 | super(); 15 | Error.captureStackTrace(this, this.constructor); 16 | this.name = this.constructor.name; 17 | this.message = message || 'Something went wrong. Please try again.'; 18 | this.statusCode = statusCode || 500; 19 | } 20 | } 21 | 22 | module.exports = ApiError; 23 | -------------------------------------------------------------------------------- /client/src/app/guards/schedule.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, Router } from '@angular/router'; 3 | import { UserService } from 'src/app/components/home/services/user.service'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class ScheduleGuard implements CanActivate { 9 | constructor( 10 | private router: Router, 11 | private userService: UserService 12 | ) { } 13 | 14 | canActivate() { 15 | if (this.userService.scheduleByHours) { 16 | return true; 17 | } 18 | this.router.navigateByUrl('/home/period'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/src/app/components/login/login.component.scss: -------------------------------------------------------------------------------- 1 | mat-card-content { 2 | margin: 40px 20px !important; 3 | } 4 | 5 | mat-progress-bar { 6 | position: absolute; 7 | top: 0; 8 | left: 0; 9 | width: 100%; 10 | } 11 | 12 | .app-name { 13 | font-size: 22px; 14 | } 15 | 16 | input { 17 | letter-spacing: 0.1em; 18 | } 19 | 20 | .login-info, .home-link { 21 | font-size: 0.9em; 22 | text-align: center; 23 | letter-spacing: 0.1em; 24 | } 25 | 26 | .home-link { 27 | cursor: pointer; 28 | color: #1e88e5; 29 | margin-top: 12px !important; 30 | &:hover { 31 | text-decoration: underline; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/src/app/guards/google-oauth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, Router } from '@angular/router'; 3 | import { UserService } from 'src/app/components/home/services/user.service'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class GoogleOauthGuard implements CanActivate { 9 | constructor( 10 | private router: Router, 11 | private userService: UserService 12 | ) { } 13 | 14 | canActivate() { 15 | if (this.userService.googleOauthData) { 16 | return true; 17 | } 18 | this.router.navigateByUrl('/home/export/options'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /client/src/app/shared/dialogs/confirmation-dialog/confirmation-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; 3 | import { DialogData } from 'src/app/models/dialog-data.model'; 4 | 5 | @Component({ 6 | selector: 'app-confirmation-dialog', 7 | templateUrl: './confirmation-dialog.component.html', 8 | styleUrls: ['./confirmation-dialog.component.scss'] 9 | }) 10 | export class ConfirmationDialogComponent { 11 | 12 | constructor( 13 | public dialogRef: MatDialogRef, 14 | @Inject(MAT_DIALOG_DATA) public data: DialogData 15 | ) { } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/server.yml: -------------------------------------------------------------------------------- 1 | name: Deploy server 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - name: Install dependencies 18 | run: npm ci 19 | working-directory: server 20 | - name: Serverless deploy 21 | run: npm run deploy 22 | env: 23 | SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }} 24 | working-directory: server 25 | -------------------------------------------------------------------------------- /client/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |

|

5 | 6 |

|

7 | 8 | 9 |

10 | Código fuente 17 |

18 |
19 | -------------------------------------------------------------------------------- /client/src/assets/google_g_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/app/services/statistics.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient, HttpHeaders } from '@angular/common/http'; 3 | import { environment } from 'src/environments/environment'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class StatisticsService { 9 | 10 | private API_URL = `${environment.apiUrl}/statistics`; 11 | private BASE_HEADER = new HttpHeaders({ 12 | 'Content-Type': 'application/json' 13 | }); 14 | 15 | constructor( 16 | private httpClient: HttpClient, 17 | ) { } 18 | 19 | getStatistics() { 20 | return this.httpClient.get(`${this.API_URL}/users`, { 21 | headers: this.BASE_HEADER 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/app/shared/dialogs/subject-details-dialog/subject-details-dialog.component.scss: -------------------------------------------------------------------------------- 1 | h1 { 2 | text-transform: uppercase; 3 | letter-spacing: 0.1em; 4 | } 5 | 6 | p { 7 | font-size: 14px; 8 | letter-spacing: 0.1em; 9 | } 10 | 11 | button.close { 12 | background-color: transparent; 13 | border-radius: 50%; 14 | } 15 | 16 | .color-picker { 17 | cursor: pointer; 18 | min-width: 58px; 19 | max-width: 58px; 20 | height: 42px; 21 | background: whitesmoke; 22 | border-radius: 4px; 23 | padding: 2.5px 0px 2.5px 5px; 24 | 25 | span { 26 | display: block; 27 | border-radius: 4px; 28 | width: 80%; 29 | height: 80%; 30 | } 31 | 32 | mat-icon { 33 | color: grey; 34 | } 35 | } -------------------------------------------------------------------------------- /client/replace.build.js: -------------------------------------------------------------------------------- 1 | const replace = require('replace-in-file'); 2 | 3 | const args = process.argv.slice(2); 4 | const replacements = args.map(arg => { 5 | const [key, value] = arg.split("="); 6 | 7 | return { 8 | files: 'src/environments/environment.prod.ts', 9 | from: new RegExp(`{${key}}`, 'g'), 10 | to: value, 11 | allowEmptyPaths: false, 12 | }; 13 | }); 14 | 15 | try { 16 | replacements.forEach((replacement) => { 17 | const results = replace.sync(replacement); 18 | if(results[0].hasChanged === true){ 19 | console.log(`Replaced ${replacement.from} in file ${replacement.files}`); 20 | } 21 | }); 22 | } 23 | catch (error) { 24 | console.error('Error occurred:', error); 25 | } 26 | -------------------------------------------------------------------------------- /client/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /client/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('Welcome to ui!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /client/src/app/components/help/help.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { HelpComponent } from './help.component'; 4 | 5 | describe('HelpComponent', () => { 6 | let component: HelpComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ HelpComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(HelpComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/app/components/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { HomeComponent } from './home.component'; 4 | 5 | describe('HomeComponent', () => { 6 | let component: HomeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ HomeComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(HomeComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/ngsw-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/service-worker/config/schema.json", 3 | "index": "/index.html", 4 | "assetGroups": [ 5 | { 6 | "name": "app", 7 | "installMode": "prefetch", 8 | "resources": { 9 | "files": [ 10 | "/favicon.ico", 11 | "/index.html", 12 | "/manifest.webmanifest", 13 | "/*.css", 14 | "/*.js" 15 | ] 16 | } 17 | }, 18 | { 19 | "name": "assets", 20 | "installMode": "lazy", 21 | "updateMode": "prefetch", 22 | "resources": { 23 | "files": [ 24 | "/assets/**", 25 | "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" 26 | ] 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /client/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | position: relative; 4 | height: 100%; 5 | } 6 | 7 | footer { 8 | position: absolute; 9 | bottom: 0; 10 | right: 0; 11 | z-index: 1; 12 | margin: 0; 13 | margin-right: 10px; 14 | font-size: 10px; 15 | text-align: right; 16 | color: #333; 17 | margin: 4px; 18 | 19 | p { 20 | margin-right: 2px; 21 | } 22 | 23 | a:link, .link { 24 | cursor: pointer; 25 | font-weight: 500; 26 | text-decoration: underline; 27 | } 28 | a:hover, a:active, a:link, a:visited, .link { 29 | color: #333; 30 | } 31 | } 32 | 33 | @media all and (max-width: 425px) { 34 | .credits { 35 | font-size: 8px; 36 | p { 37 | margin-right: 1px; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": [ 3 | { 4 | "target": "ui", 5 | "public": "dist/client", 6 | "ignore": [ 7 | "**/.*" 8 | ], 9 | "headers": [ 10 | { 11 | "source": "*.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].+(css|js)", 12 | "headers": [ 13 | { 14 | "key": "Cache-Control", 15 | "value": "public,max-age=31536000,immutable" 16 | } 17 | ] 18 | } 19 | ], 20 | "rewrites": [ 21 | { 22 | "source": "**", 23 | "destination": "/index.html" 24 | } 25 | ] 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /client/src/app/components/index/index.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { IndexComponent } from './index.component'; 4 | 5 | describe('IndexComponent', () => { 6 | let component: IndexComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ IndexComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(IndexComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/app/components/login/login.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LoginComponent } from './login.component'; 4 | 5 | describe('LoginComponent', () => { 6 | let component: LoginComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ LoginComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(LoginComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/app/shared/schedule/schedule.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ScheduleComponent } from './schedule.component'; 4 | 5 | describe('ScheduleComponent', () => { 6 | let component: ScheduleComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ScheduleComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ScheduleComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /server/src/services/firebase.js: -------------------------------------------------------------------------------- 1 | const admin = require('firebase-admin'); 2 | 3 | const config = require('../config'); 4 | 5 | const { USERS_BY_PAGE, credentials } = config.firebase; 6 | 7 | admin.initializeApp({ 8 | credential: admin.credential.cert(credentials), 9 | databaseURL: 'https://mihorarioun.firebaseio.com', 10 | }); 11 | 12 | const listAllUsers = async (nextPageToken, usersCounter = 0) => { 13 | try { 14 | const { users, pageToken } = await admin 15 | .auth() 16 | .listUsers(USERS_BY_PAGE, nextPageToken); 17 | 18 | if (pageToken) { 19 | return listAllUsers(pageToken, usersCounter + USERS_BY_PAGE); 20 | } 21 | 22 | return usersCounter + users.length; 23 | } catch (error) { 24 | return usersCounter; 25 | } 26 | }; 27 | 28 | module.exports = { 29 | listAllUsers, 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/app/components/home/home.component.scss: -------------------------------------------------------------------------------- 1 | mat-toolbar { 2 | background-color: transparent; 3 | 4 | mat-icon { 5 | cursor: pointer; 6 | color: white; 7 | } 8 | } 9 | 10 | mat-sidenav-container, mat-sidenav-content { 11 | background: transparent; 12 | } 13 | 14 | .toolbar { 15 | &-spacer { 16 | width: 90%; 17 | } 18 | } 19 | 20 | .option { 21 | font-size: 16px; 22 | padding-right: 20px; 23 | font-weight: normal; 24 | mat-icon { 25 | margin-left: 5px; 26 | } 27 | } 28 | 29 | .title { 30 | padding-left: 10px; 31 | } 32 | 33 | .small { 34 | padding-left: 0; 35 | font-size: 20px; 36 | } 37 | 38 | mat-toolbar-row.small { 39 | padding: 5px; 40 | } 41 | 42 | mat-nav-list span p { 43 | cursor: pointer; 44 | text-transform: uppercase; 45 | letter-spacing: 0.1em; 46 | padding: 5px 10px; 47 | } 48 | -------------------------------------------------------------------------------- /client/src/app/components/privacy-policy/privacy-policy.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PrivacyPolicyComponent } from './privacy-policy.component'; 4 | 5 | describe('PrivacyPolicyComponent', () => { 6 | let component: PrivacyPolicyComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ PrivacyPolicyComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PrivacyPolicyComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/export-calendar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ExportCalendarComponent } from './export-calendar.component'; 4 | 5 | describe('ExportCalendarComponent', () => { 6 | let component: ExportCalendarComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ExportCalendarComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ExportCalendarComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/app/shared/period-selector/period-selector.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PeriodSelectorComponent } from './period-selector.component'; 4 | 5 | describe('PeriodSelectorComponent', () => { 6 | let component: PeriodSelectorComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ PeriodSelectorComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PeriodSelectorComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /server/src/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | 4 | const logger = require('./utils/logger'); 5 | const api = require('./api/v1'); 6 | const { server } = require('./config'); 7 | 8 | const app = express(); 9 | 10 | app.use(cors({ 11 | origin: [server.clientName], 12 | })); 13 | 14 | app.use(express.urlencoded({ extended: true })); 15 | app.use(express.json()); 16 | 17 | app.use('/api/v1', api); 18 | 19 | app.use((req, res, next) => { 20 | res.status(404); 21 | res.json({ 22 | error: true, 23 | message: 'Not found', 24 | }); 25 | }); 26 | 27 | app.use((err, req, res, next) => { 28 | const { 29 | statusCode = 500, message, 30 | } = err; 31 | logger.error(err); 32 | res.status(statusCode); 33 | res.json({ 34 | error: true, 35 | message, 36 | }); 37 | }); 38 | 39 | module.exports = app; 40 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /public 6 | /tmp 7 | /out-tsc 8 | # Only exists if Bazel was run 9 | /bazel-out 10 | 11 | # dependencies 12 | /node_modules 13 | 14 | # profiling files 15 | chrome-profiler-events.json 16 | speed-measure-plugin.json 17 | 18 | # IDEs and editors 19 | /.idea 20 | .project 21 | .classpath 22 | .c9/ 23 | *.launch 24 | .settings/ 25 | *.sublime-workspace 26 | 27 | # IDE - VSCode 28 | .vscode/* 29 | !.vscode/settings.json 30 | !.vscode/tasks.json 31 | !.vscode/launch.json 32 | !.vscode/extensions.json 33 | .history/* 34 | 35 | # misc 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | 45 | # System Files 46 | .DS_Store 47 | Thumbs.db 48 | 49 | environment.ts -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/components/calendar-options/calendar-options.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { CalendarOptionsComponent } from './calendar-options.component'; 4 | 5 | describe('CalendarOptionsComponent', () => { 6 | let component: CalendarOptionsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ CalendarOptionsComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(CalendarOptionsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/app/shared/dialogs/confirmation-dialog/confirmation-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ConfirmationDialogComponent } from './confirmation-dialog.component'; 4 | 5 | describe('ConfirmationDialogComponent', () => { 6 | let component: ConfirmationDialogComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ConfirmationDialogComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ConfirmationDialogComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/components/subjects-selector/subjects-selector.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SubjectsSelectorComponent } from './subjects-selector.component'; 4 | 5 | describe('SubjectsSelectorComponent', () => { 6 | let component: SubjectsSelectorComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ SubjectsSelectorComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(SubjectsSelectorComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/app/shared/dialogs/subject-details-dialog/subject-details-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SubjectDetailsDialogComponent } from './subject-details-dialog.component'; 4 | 5 | describe('SubjectDetailsDialogComponent', () => { 6 | let component: SubjectDetailsDialogComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ SubjectDetailsDialogComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(SubjectDetailsDialogComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | 'browserName': 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function() {} 25 | }, 26 | onPrepare() { 27 | require('ts-node').register({ 28 | project: require('path').join(__dirname, './tsconfig.json') 29 | }); 30 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 31 | } 32 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /server/serverless.yml: -------------------------------------------------------------------------------- 1 | org: sjdonado 2 | app: mihorarioun 3 | service: mihorarioun 4 | 5 | frameworkVersion: '3' 6 | 7 | useDotenv: true 8 | 9 | provider: 10 | name: aws 11 | runtime: nodejs18.x 12 | lambdaHashingVersion: '20201221' 13 | timeout: 10 14 | environment: 15 | GOOGLE_CALENDAR_CALLBACK: ${param:GOOGLE_CALENDAR_CALLBACK} 16 | CLIENT_NAME: ${param:CLIENT_NAME} 17 | GOOGLE_CLIENT_ID: ${param:GOOGLE_CLIENT_ID} 18 | GOOGLE_SECRET_CLIENT: ${param:GOOGLE_SECRET_CLIENT} 19 | HOSTNAME: ${param:HOSTNAME} 20 | PINO_LEVEL: ${param:PINO_LEVEL} 21 | POMELO_BASE_URL: ${param:POMELO_BASE_URL} 22 | SECRET: ${param:SECRET} 23 | FIREBASE_PRIVATE_KEY: ${param:FIREBASE_PRIVATE_KEY} 24 | 25 | functions: 26 | api: 27 | handler: handler.handler 28 | events: 29 | - http: 30 | path: / 31 | method: ANY 32 | - http: 33 | path: /{proxy+} 34 | method: ANY 35 | timeout: 10 36 | 37 | plugins: 38 | - serverless-offline 39 | -------------------------------------------------------------------------------- /.github/workflows/client.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy client to Firebase 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@master 15 | - name: Install Dependencies 16 | working-directory: client 17 | run: npm ci --legacy-peer-deps 18 | - name: Setup firebase api key 19 | working-directory: client 20 | run: npm run build:update FIREBASE_API_KEY=${{ secrets.FIREBASE_API_KEY }} LAMBDA_API_URL=${{ secrets.LAMBDA_API_URL }} 21 | - name: Build 22 | working-directory: client 23 | env: 24 | NODE_OPTIONS: --openssl-legacy-provider 25 | run: npm run build:prod 26 | - name: Deploy to Firebase 27 | uses: w9jds/firebase-action@master 28 | with: 29 | args: deploy --only hosting 30 | env: 31 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 32 | -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/components/subjects-selector/subjects-selector.component.scss: -------------------------------------------------------------------------------- 1 | $border-color: rgb(221, 221, 221); 2 | 3 | .subjects-wrapper { 4 | height: 330px; 5 | overflow-y: auto; 6 | } 7 | 8 | .subject { 9 | padding-left: 15px; 10 | padding-right: 10px; 11 | } 12 | 13 | div { 14 | p { 15 | margin: 0; 16 | max-width: 250px; 17 | white-space: nowrap; 18 | overflow: hidden; 19 | text-overflow: ellipsis; 20 | } 21 | } 22 | 23 | mat-checkbox { 24 | margin: 10px 0; 25 | padding-bottom: 10px; 26 | border-bottom: 1px solid $border-color; 27 | } 28 | 29 | .more-btn { 30 | cursor: pointer; 31 | } 32 | 33 | mat-icon { 34 | margin-right: 2.5px; 35 | } 36 | 37 | .subject-color-wrapper { 38 | margin-left: 20px; 39 | } 40 | 41 | .subject-color { 42 | margin-left: 5px; 43 | width: 28px; 44 | height: 28px; 45 | border: 1px solid $border-color; 46 | } 47 | 48 | .select-all { 49 | text-transform: uppercase; 50 | margin: 10px; 51 | margin-left: 15px; 52 | } 53 | 54 | .subject-sync-wrapper { 55 | margin-left: 10px; 56 | } 57 | -------------------------------------------------------------------------------- /server/src/services/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scraper service 3 | * @author sjdonado 4 | * @since 1.0.0 5 | */ 6 | 7 | const { sign, verify } = require('jsonwebtoken'); 8 | 9 | const ApiError = require('../lib/ApiError'); 10 | const { server } = require('../config'); 11 | 12 | /** 13 | * Generate new token 14 | * @param {Object} payload 15 | * @param {String} expiration time 16 | */ 17 | const signToken = (payload, expiresIn = '24h') => sign(payload, server.secret, { 18 | algorithm: 'HS256', 19 | expiresIn, 20 | }); 21 | 22 | const auth = (req, res, next) => { 23 | const token = req.headers.authorization || req.query.token || req.body.token; 24 | 25 | if (!token) { 26 | next(new ApiError('Unauthorized', 401)); 27 | return; 28 | } 29 | 30 | verify(token, server.secret, (err, decoded) => { 31 | if (err) { 32 | next(new ApiError('Unauthorized', 401)); 33 | return; 34 | } 35 | const { credentials, userId } = decoded; 36 | 37 | req.user = { credentials, userId }; 38 | next(); 39 | }); 40 | }; 41 | 42 | module.exports = { 43 | signToken, 44 | auth, 45 | }; 46 | -------------------------------------------------------------------------------- /client/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | import { Routes, RouterModule } from "@angular/router"; 3 | import { LoginComponent } from "./components/login/login.component"; 4 | import { PrivacyPolicyComponent } from "./components/privacy-policy/privacy-policy.component"; 5 | import { IndexComponent } from "./components/index/index.component"; 6 | import { HelpComponent } from "./components/help/help.component"; 7 | 8 | const routes: Routes = [ 9 | { 10 | path: "", 11 | component: IndexComponent, 12 | }, 13 | // { 14 | // path: 'login', 15 | // component: LoginComponent 16 | // }, 17 | { 18 | path: "privacy-policy", 19 | component: PrivacyPolicyComponent, 20 | }, 21 | { 22 | path: "help", 23 | component: HelpComponent, 24 | }, 25 | // { 26 | // path: 'home', 27 | // loadChildren: () => import('./components/home/home.module').then(m => m.HomeModule), 28 | // }, 29 | { path: "**", redirectTo: "/" }, 30 | ]; 31 | 32 | @NgModule({ 33 | imports: [RouterModule.forRoot(routes)], 34 | exports: [RouterModule], 35 | }) 36 | export class AppRoutingModule { } 37 | -------------------------------------------------------------------------------- /client/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | export const environment = { 5 | production: false, 6 | cookies: { 7 | expires: 1, 8 | path: '/', 9 | domain: 'localhost', 10 | secure: false, 11 | }, 12 | apiUrl: 'http://localhost:3000/dev/api/v1', 13 | firebase: { 14 | apiKey: '', 15 | authDomain: 'mihorarioun.firebaseapp.com', 16 | databaseURL: '', 17 | projectId: 'mihorarioun', 18 | storageBucket: '', 19 | messagingSenderId: '119902822900' 20 | } 21 | }; 22 | 23 | /* 24 | * For easier debugging in development mode, you can import the following file 25 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 26 | * 27 | * This import should be commented out in production mode because it will have a negative impact 28 | * on performance if an error is thrown. 29 | */ 30 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 31 | -------------------------------------------------------------------------------- /client/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/ui'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /server/src/lib/Date.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment-timezone'); 2 | 3 | const TIME_ZONE = 'America/Bogota'; 4 | 5 | /** 6 | * parsePomeloDateTime 7 | * @param {*} dateTime Pomelo date time MMM DD, YYYY 8 | * @param {boolean} utcOffset America/Bogota offset 9 | */ 10 | const parsePomeloDateTime = (pomeloDate, utcOffset = false) => { 11 | const momentDate = moment(`${pomeloDate}`, 'MMM DD, YYYY', 'es'); 12 | if (utcOffset) { 13 | return momentDate.tz(TIME_ZONE).format(); 14 | } 15 | return momentDate.format(); 16 | }; 17 | 18 | /** 19 | * parsePomeloDateToCalendar 20 | * @param {*} dateTime Pomelo date time MMM DD, YYYY or momentDate 21 | * @param {boolean} parse Parse pomelo date time 22 | */ 23 | const parsePomeloDateToCalendar = (dateTime, parse = false) => { 24 | const response = { 25 | timeZone: TIME_ZONE, 26 | }; 27 | if (parse) { 28 | Object.assign(response, { dateTime: parsePomeloDateTime(dateTime, true) }); 29 | } else { 30 | Object.assign(response, { dateTime: dateTime.tz(TIME_ZONE).format() }); 31 | } 32 | return response; 33 | }; 34 | 35 | module.exports = { 36 | parsePomeloDateTime, 37 | parsePomeloDateToCalendar, 38 | }; 39 | -------------------------------------------------------------------------------- /client/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async(() => { 7 | TestBed.configureTestingModule({ 8 | imports: [ 9 | RouterTestingModule 10 | ], 11 | declarations: [ 12 | AppComponent 13 | ], 14 | }).compileComponents(); 15 | })); 16 | 17 | it('should create the app', () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.debugElement.componentInstance; 20 | expect(app).toBeTruthy(); 21 | }); 22 | 23 | it(`should have as title 'ui'`, () => { 24 | const fixture = TestBed.createComponent(AppComponent); 25 | const app = fixture.debugElement.componentInstance; 26 | expect(app.title).toEqual('ui'); 27 | }); 28 | 29 | it('should render title in a h1 tag', () => { 30 | const fixture = TestBed.createComponent(AppComponent); 31 | fixture.detectChanges(); 32 | const compiled = fixture.debugElement.nativeElement; 33 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to ui!'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /client/src/theme.scss: -------------------------------------------------------------------------------- 1 | @import '~@angular/material/theming'; 2 | // Plus imports for other components in your app. 3 | 4 | // Include the common styles for Angular Material. We include this here so that you only 5 | // have to load a single css file for Angular Material in your app. 6 | // Be sure that you only ever include this mixin once! 7 | @include mat-core(); 8 | 9 | // Define the palettes for your theme using the Material Design palettes available in palette.scss 10 | // (imported above). For each palette, you can optionally specify a default, lighter, and darker 11 | // hue. Available color palettes: https://material.io/design/color/ 12 | $candy-app-primary: mat-palette($mat-blue, 600); 13 | $candy-app-accent: mat-palette($mat-pink, A200, A100, A400); 14 | 15 | // The warn palette is optional (defaults to red). 16 | $candy-app-warn: mat-palette($mat-red); 17 | 18 | // Create the theme object (a Sass map containing all of the palettes). 19 | $candy-app-theme: mat-light-theme($candy-app-primary, $candy-app-accent, $candy-app-warn); 20 | 21 | // Include theme styles for core and each component used in your app. 22 | // Alternatively, you can import and @include the theme mixins for each component 23 | // that you are using. 24 | @include angular-material-theme($candy-app-theme); -------------------------------------------------------------------------------- /server/src/api/v1/users/controller.js: -------------------------------------------------------------------------------- 1 | const ApiError = require('../../../lib/ApiError'); 2 | const { pomeloSchedule, pomeloUserId, pomeloScheduleTerms } = require('./model'); 3 | const { signToken } = require('../../../services/auth'); 4 | const { listAllUsers } = require('../../../services/firebase'); 5 | 6 | const getSchedule = async (req, res, next) => { 7 | try { 8 | const { termId } = req.query; 9 | if (!termId) throw new ApiError('Start date is not valid', 400); 10 | 11 | const { credentials, userId } = req.user; 12 | 13 | const data = await pomeloSchedule(credentials, userId, termId); 14 | 15 | res.json({ data }); 16 | } catch (err) { 17 | next(err); 18 | } 19 | }; 20 | 21 | const login = async (req, res, next) => { 22 | try { 23 | const { username, password } = req.body; 24 | if (!username || !password) next(new ApiError('Bad request', 400)); 25 | 26 | const credentials = { username, password }; 27 | 28 | const userId = await pomeloUserId(credentials); 29 | const token = signToken({ credentials, userId }); 30 | 31 | const pomelo = await pomeloScheduleTerms(credentials, userId); 32 | 33 | res.json({ data: { token, pomelo } }); 34 | } catch (err) { 35 | next(err); 36 | } 37 | }; 38 | 39 | module.exports = { 40 | getSchedule, 41 | login, 42 | }; 43 | -------------------------------------------------------------------------------- /server/src/config/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const config = { 4 | server: { 5 | port: process.env.PORT, 6 | clientName: process.env.CLIENT_NAME, 7 | secret: process.env.SECRET, 8 | }, 9 | calendar: { 10 | clientId: process.env.GOOGLE_CLIENT_ID, 11 | secretClient: process.env.GOOGLE_SECRET_CLIENT, 12 | callback: process.env.GOOGLE_CALENDAR_CALLBACK, 13 | }, 14 | pomelo: { 15 | baseURL: process.env.POMELO_BASE_URL, 16 | }, 17 | firebase: { 18 | credentials: { 19 | type: 'service_account', 20 | project_id: 'mihorarioun', 21 | private_key_id: 'fb2cc9a1a1d385d7c65b7ebb668fc261bdfba9df', 22 | private_key: process.env.FIREBASE_PRIVATE_KEY ? process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n') : null, 23 | client_email: 'firebase-adminsdk-tqifm@mihorarioun.iam.gserviceaccount.com', 24 | client_id: '118373676866750868350', 25 | auth_uri: 'https://accounts.google.com/o/oauth2/auth', 26 | token_uri: 'https://oauth2.googleapis.com/token', 27 | auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', 28 | client_x509_cert_url: 'https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-tqifm%40mihorarioun.iam.gserviceaccount.com', 29 | }, 30 | USERS_BY_PAGE: 1000, 31 | }, 32 | }; 33 | 34 | module.exports = config; 35 | -------------------------------------------------------------------------------- /client/src/app/components/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 7 |

{{ title }}

8 | 9 |
10 | 11 |

{{ fullName }}

12 | logout 13 |
14 |
15 |
16 |
17 | 18 | 19 | 20 |

{{ fullName }}

21 | 22 |

Cerrar sesión

23 | logout 24 |
25 |
26 |
27 | 28 | 29 | 30 |
31 |
32 | -------------------------------------------------------------------------------- /server/tests/services/firebase.test.js: -------------------------------------------------------------------------------- 1 | const admin = require('firebase-admin'); 2 | const firebaseService = require('../../src/services/firebase'); 3 | 4 | jest.mock('firebase-admin'); 5 | 6 | admin.initializeApp = jest.fn(); 7 | 8 | describe('Test firebase service', () => { 9 | afterEach(() => { 10 | global.axiosMock.resetHistory(); 11 | }); 12 | 13 | it('Should get countAllUsers', async () => { 14 | const TOTAL_USERS = 2300; 15 | let remainingUsers = TOTAL_USERS; 16 | 17 | admin.auth = jest.fn(() => ({ 18 | listUsers: jest.fn((usersByPage) => { 19 | const response = { 20 | users: new Array(remainingUsers < usersByPage ? remainingUsers : usersByPage), 21 | pageToken: remainingUsers - usersByPage > 0, 22 | }; 23 | remainingUsers -= usersByPage; 24 | return response; 25 | }), 26 | })); 27 | 28 | const totalUsersCounter = await firebaseService.listAllUsers(); 29 | 30 | expect(totalUsersCounter).toBe(TOTAL_USERS); 31 | }); 32 | 33 | it('Should get countAllUsers with error', async () => { 34 | admin.auth = jest.fn(() => ({ 35 | listUsers: jest.fn(() => { 36 | throw new Error('Unexpected error'); 37 | }), 38 | })); 39 | 40 | const totalUsersCounter = await firebaseService.listAllUsers(); 41 | 42 | expect(totalUsersCounter).toBe(0); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /server/src/api/v1/googleCalendar/controller.js: -------------------------------------------------------------------------------- 1 | const { 2 | getSyncedSubjects, 3 | importSchedule, 4 | removeSubjects, 5 | } = require('./model'); 6 | 7 | const syncSchedule = async (req, res, next) => { 8 | try { 9 | const { accessToken, refreshToken, subjects } = req.body; 10 | 11 | const data = await getSyncedSubjects({ 12 | access_token: accessToken, 13 | refresh_token: refreshToken, 14 | }, subjects); 15 | res.json({ data }); 16 | } catch (err) { 17 | next(err); 18 | } 19 | }; 20 | 21 | const importSubjectsToCalendar = async (req, res, next) => { 22 | try { 23 | const { accessToken, refreshToken, subjectsMatrix } = req.body; 24 | const data = await importSchedule({ 25 | access_token: accessToken, 26 | refresh_token: refreshToken, 27 | }, subjectsMatrix); 28 | res.json({ data }); 29 | } catch (err) { 30 | next(err); 31 | } 32 | }; 33 | 34 | const removeSubjectsFromCalendar = async (req, res, next) => { 35 | try { 36 | const { accessToken, refreshToken, subjects } = req.body; 37 | const data = await removeSubjects({ 38 | access_token: accessToken, 39 | refresh_token: refreshToken, 40 | }, subjects); 41 | res.json({ data }); 42 | } catch (err) { 43 | next(err); 44 | } 45 | }; 46 | 47 | module.exports = { 48 | syncSchedule, 49 | importSubjectsToCalendar, 50 | removeSubjectsFromCalendar, 51 | }; 52 | -------------------------------------------------------------------------------- /client/src/app/services/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { MatSnackBar } from '@angular/material/snack-bar'; 3 | 4 | interface Message { 5 | text: string; 6 | duration: number; 7 | } 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class NotificationService { 13 | 14 | GENERAL_ERROR_MESSAGE = 'Ocurrió un error'; 15 | private isInstanceVisible: boolean; 16 | private msgQueue: Message[]; 17 | 18 | constructor( 19 | private snackBar: MatSnackBar 20 | ) { 21 | this.msgQueue = []; 22 | this.isInstanceVisible = false; 23 | } 24 | 25 | add(text: string, duration: number = 3000) { 26 | this.msgQueue.push({ text, duration }); 27 | if (!this.isInstanceVisible) { 28 | this.showNext(); 29 | } 30 | } 31 | 32 | showNext() { 33 | if (this.msgQueue.length === 0) { 34 | return; 35 | } 36 | console.log('msgQueue', this.msgQueue); 37 | const message = this.msgQueue.shift(); 38 | this.isInstanceVisible = true; 39 | const snackBarRef = this.snackBar.open(message.text, 'Cerrar', { duration: message.duration }); 40 | snackBarRef.afterDismissed().subscribe(() => { 41 | this.isInstanceVisible = false; 42 | this.showNext(); 43 | }); 44 | } 45 | 46 | stopAll() { 47 | this.msgQueue = []; 48 | this.isInstanceVisible = false; 49 | } 50 | 51 | isInQueue(text: string) { 52 | return this.msgQueue.some(elem => elem.text === text); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mi horario UN", 3 | "short_name": "mihorarioun", 4 | "theme_color": "#60a1eb", 5 | "background_color": "#fafafa", 6 | "display": "standalone", 7 | "scope": "./", 8 | "start_url": "./", 9 | "icons": [ 10 | { 11 | "src": "assets/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png", 14 | "purpose": "maskable any" 15 | }, 16 | { 17 | "src": "assets/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png", 20 | "purpose": "maskable any" 21 | }, 22 | { 23 | "src": "assets/icons/icon-128x128.png", 24 | "sizes": "128x128", 25 | "type": "image/png", 26 | "purpose": "maskable any" 27 | }, 28 | { 29 | "src": "assets/icons/icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "purpose": "maskable any" 33 | }, 34 | { 35 | "src": "assets/icons/icon-152x152.png", 36 | "sizes": "152x152", 37 | "type": "image/png", 38 | "purpose": "maskable any" 39 | }, 40 | { 41 | "src": "assets/icons/icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "purpose": "maskable any" 45 | }, 46 | { 47 | "src": "assets/icons/icon-384x384.png", 48 | "sizes": "384x384", 49 | "type": "image/png", 50 | "purpose": "maskable any" 51 | }, 52 | { 53 | "src": "assets/icons/icon-512x512.png", 54 | "sizes": "512x512", 55 | "type": "image/png", 56 | "purpose": "maskable any" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /client/src/app/components/index/index.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, OnInit, ViewChild } from "@angular/core"; 2 | import { AppComponent } from "src/app/app.component"; 3 | import { StatisticsService } from "src/app/services/statistics.service"; 4 | 5 | @Component({ 6 | selector: "app-index", 7 | templateUrl: "./index.component.html", 8 | styleUrls: ["./index.component.scss"], 9 | }) 10 | export class IndexComponent implements OnInit { 11 | public title: string; 12 | @ViewChild("counter") counter: ElementRef; 13 | 14 | constructor( 15 | private appComponent: AppComponent, 16 | private statisticsService: StatisticsService, 17 | ) { } 18 | 19 | ngOnInit() { 20 | this.title = this.appComponent.title; 21 | } 22 | 23 | ngAfterViewInit() { 24 | // this.statisticsService.getStatistics().subscribe( 25 | // (response: any) => { 26 | // this.animateValue(this.counter.nativeElement, 0, response.data.totalUsersCounter, 5000); 27 | // }, (err) => { 28 | // console.log('Error: ' + err); 29 | // }, 30 | // ); 31 | this.animateValue(this.counter.nativeElement, 0, 7833, 1000); 32 | } 33 | 34 | animateValue(obj, start, end, duration) { 35 | let startTimestamp = null; 36 | const step = (timestamp) => { 37 | if (!startTimestamp) startTimestamp = timestamp; 38 | const progress = Math.min((timestamp - startTimestamp) / duration, 1); 39 | obj.innerHTML = Math.floor(progress * (end - start) + start); 40 | if (progress < 1) { 41 | window.requestAnimationFrame(step); 42 | } 43 | }; 44 | window.requestAnimationFrame(step); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/app/shared/period-selector/period-selector.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

5 | Hola, {{ name }} 6 |

7 |

8 | Selecciona un periodo 9 |

10 |
11 |
12 | 13 | 14 | 15 | arrow_back 16 | 17 |
18 | 19 | Periodo académico 20 | 21 | 22 | {{ term.name }} 23 | 24 | 25 | 26 | 27 |
28 |
29 |
30 |
31 |
32 | 33 |
34 | 35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /client/src/app/interceptors/auth.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpInterceptor, 3 | HttpRequest, 4 | HttpHandler, 5 | HttpEvent, 6 | } from '@angular/common/http'; 7 | import { CookieService } from 'ngx-cookie-service'; 8 | import { Observable } from 'rxjs'; 9 | import { tap } from 'rxjs/operators'; 10 | import { Injectable } from '@angular/core'; 11 | import { Router } from '@angular/router'; 12 | import { NotificationService } from '../services/notification.service'; 13 | 14 | @Injectable() 15 | export class AuthInterceptor implements HttpInterceptor { 16 | 17 | constructor( 18 | private router: Router, 19 | private notificationService: NotificationService, 20 | private cookieService: CookieService, 21 | ) { } 22 | 23 | intercept(req: HttpRequest, next: HttpHandler): 24 | Observable> { 25 | return next.handle(req) 26 | .pipe(tap( 27 | () => { }, 28 | err => { 29 | if (req.headers.has('Authorization') && err.status === 401) { 30 | this.invalidAuthLocalData(); 31 | } else { 32 | console.log('I', err); 33 | // const message = err.error.error && err.error.error.message ? 34 | // err.error.error.message : this.notificationService.GENERAL_ERROR_MESSAGE; 35 | // this.notificationService.add(message); 36 | } 37 | } 38 | ) 39 | ); 40 | } 41 | 42 | private invalidAuthLocalData() { 43 | this.notificationService.add('Tiempo límite de sesión web ha expirado, ingrese nuevamente.'); 44 | this.cookieService.deleteAll(); 45 | localStorage.clear(); 46 | this.router.navigate(['/login']); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mihorario", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "dev": "serverless offline", 7 | "test": "PINO_LEVEL=error jest --coverage --testTimeout 7000", 8 | "test:debug": "PINO_LEVEL=error node --inspect-brk node_modules/.bin/jest --coverage --runInBand", 9 | "deploy": "serverless deploy" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/sjdonado/mihorario.git" 14 | }, 15 | "author": "sjdonado", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/sjdonado/mihorario/issues" 19 | }, 20 | "homepage": "https://github.com/sjdonado/mihorario#readme", 21 | "dependencies": { 22 | "axios": "^0.21.4", 23 | "axios-mock-adapter": "^1.19.0", 24 | "cors": "^2.8.5", 25 | "dotenv": "^10.0.0", 26 | "express": "^4.16.4", 27 | "firebase-admin": "^9.11.0", 28 | "googleapis": "^80.1.0", 29 | "jest": "^27.0.6", 30 | "jsonwebtoken": "^8.5.1", 31 | "moment": "^2.29.4", 32 | "moment-timezone": "^0.5.33", 33 | "pino": "^6.11.3", 34 | "pino-pretty": "^5.1.0", 35 | "serverless-http": "^3.2.0" 36 | }, 37 | "devDependencies": { 38 | "eslint": "^5.16.0", 39 | "eslint-config-airbnb-base": "^14.2.1", 40 | "eslint-plugin-import": "^2.23.4", 41 | "serverless": "^3.38.0", 42 | "serverless-offline": "^13.3.3" 43 | }, 44 | "jest": { 45 | "testEnvironment": "node", 46 | "setupFiles": [ 47 | "/tests/setup.js" 48 | ], 49 | "coveragePathIgnorePatterns": [ 50 | "/node_modules/", 51 | "/test/" 52 | ], 53 | "verbose": true 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/components/calendar-options/calendar-options.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | Exporta tu horario al calendario de Google 5 |

6 |
7 |
8 | 9 | 10 | 11 | arrow_back 12 | 13 |
14 | 15 |
16 |

Continuar como {{ googleOauthData.email }}

17 |
18 |
19 |

Iniciar sesión con otra cuenta

20 |
21 |
22 |
23 | 24 |
25 |

Iniciar sesión

26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /server/src/services/pomelo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * PomeloService 3 | * @author krthr 4 | * @author sjdonado 5 | * @since 2.0.0 6 | */ 7 | 8 | const axios = require('axios'); 9 | const { pomelo } = require('../config'); 10 | const ApiError = require('../lib/ApiError'); 11 | const logger = require('../utils/logger'); 12 | 13 | class PomeloService { 14 | constructor({ username, password }) { 15 | this.http = axios.create({ 16 | baseURL: pomelo.baseURL, 17 | auth: { username, password }, 18 | }); 19 | } 20 | 21 | async getUserId() { 22 | try { 23 | const { data } = await this.http.get('/security/getUserInfo'); 24 | return data.userId; 25 | } catch (err) { 26 | logger.error(err); 27 | throw new ApiError('Failed to get userId', err.response.status); 28 | } 29 | } 30 | 31 | async getFullNameAndTerms(userId) { 32 | try { 33 | const { data } = await this.http.get(`/courses/fullview/${userId}`); 34 | const fullName = data.person.name; 35 | const terms = data.terms.map(({ 36 | id, 37 | name, 38 | startDate, 39 | endDate, 40 | }) => ({ 41 | id, 42 | name, 43 | startDate, 44 | endDate, 45 | })); 46 | return { fullName, terms }; 47 | } catch (err) { 48 | logger.error(err); 49 | throw new ApiError('Failed to get user fullName and terms', err.response.status); 50 | } 51 | } 52 | 53 | async getSchedule(userId, termId) { 54 | try { 55 | const { data } = await this.http.get(`/courses/overview/${userId}?term=${termId}`); 56 | return data.terms[0].sections; 57 | } catch (err) { 58 | logger.error(err); 59 | throw new ApiError('Failed to get schedule', err.response.status); 60 | } 61 | } 62 | } 63 | 64 | module.exports = PomeloService; 65 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mihorario", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "NODE_OPTIONS=--openssl-legacy-provider ng serve", 7 | "build": "NODE_OPTIONS=--openssl-legacy-provider ng build", 8 | "build:prod": "ng build --configuration production", 9 | "build:update": "node ./replace.build.js", 10 | "test": "ng test", 11 | "lint": "ng lint", 12 | "e2e": "ng e2e" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular/animations": "~12.1.1", 17 | "@angular/cdk": "^12.1.1", 18 | "@angular/common": "~12.1.1", 19 | "@angular/compiler": "~12.1.1", 20 | "@angular/core": "~12.1.1", 21 | "@angular/fire": "^6.1.5", 22 | "@angular/flex-layout": "^12.0.0-beta.34", 23 | "@angular/forms": "~12.1.1", 24 | "@angular/material": "^12.1.1", 25 | "@angular/platform-browser": "~12.1.1", 26 | "@angular/platform-browser-dynamic": "~12.1.1", 27 | "@angular/pwa": "^12.1.2", 28 | "@angular/router": "~12.1.1", 29 | "@angular/service-worker": "~12.1.1", 30 | "firebase": "^8.7.1", 31 | "html2canvas": "^1.0.0-rc.5", 32 | "ngx-color": "^7.2.0", 33 | "ngx-cookie-service": "^2.2.0", 34 | "replace-in-file": "^6.2.0", 35 | "rxjs": "~6.6.0", 36 | "tslib": "^2.2.0", 37 | "zone.js": "~0.11.4" 38 | }, 39 | "devDependencies": { 40 | "@angular-devkit/build-angular": "~12.1.1", 41 | "@angular/cli": "~12.1.1", 42 | "@angular/compiler-cli": "~12.1.1", 43 | "@types/jasmine": "~3.6.0", 44 | "@types/node": "^12.11.1", 45 | "jasmine-core": "~3.7.0", 46 | "karma": "^6.3.20", 47 | "karma-chrome-launcher": "~3.1.0", 48 | "karma-coverage": "~2.0.3", 49 | "karma-jasmine": "~4.0.0", 50 | "karma-jasmine-html-reporter": "^1.5.0", 51 | "typescript": "~4.3.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/src/app/components/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | import { AuthService } from '../../services/auth.service'; 4 | import { Router } from '@angular/router'; 5 | import { AppComponent } from '../../app.component'; 6 | import { NotificationService } from 'src/app/services/notification.service'; 7 | 8 | @Component({ 9 | selector: 'app-login', 10 | templateUrl: './login.component.html', 11 | styleUrls: ['./login.component.scss'] 12 | }) 13 | export class LoginComponent implements OnInit { 14 | 15 | private form: FormGroup; 16 | public title: string; 17 | public isLoading: boolean; 18 | 19 | constructor( 20 | private formBuilder: FormBuilder, 21 | private authService: AuthService, 22 | private appComponent: AppComponent, 23 | private router: Router, 24 | private notificationService: NotificationService 25 | ) { } 26 | 27 | ngOnInit() { 28 | this.title = this.appComponent.title; 29 | if (this.authService.token) { 30 | this.router.navigateByUrl('/home'); 31 | } 32 | this.form = this.formBuilder.group({ 33 | username: [, Validators.required], 34 | password: [, Validators.required] 35 | }); 36 | } 37 | 38 | login() { 39 | this.isLoading = true; 40 | this.authService.pomeloLogin(this.form.value).subscribe( 41 | (response: Response) => { 42 | console.log(response); 43 | this.router.navigateByUrl('/home'); 44 | this.isLoading = false; 45 | }, (err) => { 46 | this.isLoading = false; 47 | this.notificationService.add('Error al iniciar sesión, intente de nuevo.'); 48 | console.log('Error: ' + err); 49 | }, 50 | ); 51 | } 52 | 53 | get getFormGroup() { 54 | return this.form; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/src/app/components/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | {{ title }} 4 |

5 |
6 | 7 | 8 | 9 |
10 | 11 | Usuario uninorte 12 | perm_identity 13 | 14 | 15 | El usuario es requerido 16 | 17 | 18 | 19 | Contraseña 20 | 21 | lock 22 | 23 | La contraseña es requerida 24 | 25 | 26 | 29 | 30 | Inicio 31 |
32 |
33 |
34 |
35 |
36 | -------------------------------------------------------------------------------- /client/src/app/components/index/index.component.scss: -------------------------------------------------------------------------------- 1 | .app-name { 2 | font-size: 24px; 3 | text-align: center; 4 | } 5 | 6 | .container { 7 | height: 65%; 8 | } 9 | 10 | .features { 11 | 12 | p, 13 | mat-icon { 14 | color: white; 15 | } 16 | 17 | p { 18 | margin: 4px; 19 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1); 20 | } 21 | 22 | div { 23 | padding-left: 20px; 24 | } 25 | 26 | .counter { 27 | margin-right: 0; 28 | } 29 | } 30 | 31 | .links { 32 | p { 33 | color: white; 34 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1); 35 | } 36 | } 37 | 38 | .link { 39 | cursor: pointer; 40 | text-align: center; 41 | 42 | &:hover { 43 | text-decoration: underline; 44 | } 45 | } 46 | 47 | .button { 48 | background: white; 49 | width: 160px; 50 | margin: auto; 51 | border-radius: 20px; 52 | box-shadow: 0 2px 30px rgba(black, 0.2); 53 | 54 | p, 55 | mat-icon { 56 | color: rgb(29, 29, 29); 57 | } 58 | 59 | p { 60 | font-size: 0.9em; 61 | margin: 10px; 62 | } 63 | 64 | &:hover { 65 | box-shadow: 0 2px 30px rgba(black, 0.4); 66 | } 67 | 68 | transition: 0.5s; 69 | } 70 | 71 | .button.disabled { 72 | pointer-events: none; 73 | opacity: 0.5; 74 | cursor: not-allowed; 75 | } 76 | 77 | .banner { 78 | position: absolute; 79 | top: 0; 80 | left: 0; 81 | background-color: #ffcccb; 82 | padding: 10px; 83 | margin-bottom: 10px; 84 | font-size: 0.9em; 85 | width: 100%; 86 | color: #d8000c; 87 | } 88 | 89 | .banner h2 { 90 | letter-spacing: 0.4em !important; 91 | font-weight: normal !important; 92 | } 93 | 94 | .banner p { 95 | width: 100%; 96 | margin: 0; 97 | text-align: left; 98 | margin: 4px 0; 99 | } 100 | 101 | .banner a { 102 | text-decoration: underline; 103 | } 104 | 105 | @media all and (max-width: 767px) { 106 | .container { 107 | height: 85%; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /client/src/app/shared/period-selector/period-selector.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 3 | import { UserService } from 'src/app/components/home/services/user.service'; 4 | import { Router } from '@angular/router'; 5 | import { AuthService } from 'src/app/services/auth.service'; 6 | import { NotificationService } from 'src/app/services/notification.service'; 7 | import { Term } from 'src/app/models/term.model'; 8 | 9 | @Component({ 10 | selector: 'app-period-selector', 11 | templateUrl: './period-selector.component.html', 12 | styleUrls: ['./period-selector.component.scss'] 13 | }) 14 | export class PeriodSelectorComponent implements OnInit { 15 | 16 | public form: FormGroup; 17 | public isLoading: boolean; 18 | private name: string; 19 | private terms: Term[]; 20 | private showGoBackButton: boolean; 21 | 22 | constructor( 23 | private formBuilder: FormBuilder, 24 | private authService: AuthService, 25 | private userService: UserService, 26 | private router: Router, 27 | private notificationService: NotificationService 28 | ) { 29 | this.showGoBackButton = this.userService.scheduleByHours !== null; 30 | } 31 | 32 | ngOnInit() { 33 | this.terms = this.authService.pomeloData.terms; 34 | this.name = this.authService.pomeloData.fullName.split(' ')[0]; 35 | this.form = this.formBuilder.group({ 36 | termId: [, Validators.required], 37 | }); 38 | } 39 | 40 | getSchedule() { 41 | this.isLoading = true; 42 | this.userService.getSchedule(this.form.value.termId).subscribe( 43 | (response: any) => { 44 | console.log(response); 45 | this.isLoading = false; 46 | this.router.navigateByUrl('/home'); 47 | }, (err) => { 48 | this.isLoading = false; 49 | this.notificationService.add('Error al obtener tu horario, intente de nuevo.'); 50 | console.log('Error: ' + err); 51 | } 52 | ); 53 | } 54 | 55 | get getFormGroup() { 56 | return this.form; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /client/src/app/components/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { CookieService } from 'ngx-cookie-service'; 3 | import { MatDialog } from '@angular/material/dialog'; 4 | import { UserService } from 'src/app/components/home/services/user.service'; 5 | import { AuthService } from '../../services/auth.service'; 6 | import { AppComponent } from '../../app.component'; 7 | import { Subject } from '../../models/subject.model'; 8 | import { ConfirmationDialogComponent } from '../../shared/dialogs/confirmation-dialog/confirmation-dialog.component'; 9 | import { Router } from '@angular/router'; 10 | import { environment } from 'src/environments/environment'; 11 | 12 | 13 | @Component({ 14 | selector: 'app-home', 15 | templateUrl: './home.component.html', 16 | styleUrls: ['./home.component.scss'] 17 | }) 18 | export class HomeComponent implements OnInit { 19 | 20 | public fullName: string; 21 | public title: string; 22 | private schedule: Subject[][]; 23 | 24 | constructor( 25 | private userService: UserService, 26 | private authService: AuthService, 27 | private appComponent: AppComponent, 28 | private router: Router, 29 | public dialog: MatDialog, 30 | private cookieService: CookieService, 31 | ) { } 32 | 33 | ngOnInit() { 34 | this.title = this.appComponent.title; 35 | this.fullName = this.authService.pomeloData.fullName; 36 | this.schedule = this.userService.scheduleByHours; 37 | } 38 | 39 | openLogoutDialog(): void { 40 | const dialogRef = this.dialog.open(ConfirmationDialogComponent, { 41 | width: `${window.innerWidth / 4 > 320 ? window.innerWidth / 4 : 320 }px`, 42 | data: { title: 'Cerrar sesión', message: '¿Estás seguro?' } 43 | }); 44 | 45 | dialogRef.afterClosed() 46 | .subscribe(result => { 47 | if (result) { 48 | this.cookieService.deleteAll( 49 | environment.cookies.path, 50 | environment.cookies.domain, 51 | ); 52 | localStorage.clear(); 53 | window.location.reload(); 54 | } 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/app/components/home/home-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { AuthGuard } from '../../guards/auth.guard'; 4 | import { ScheduleGuard } from '../../guards/schedule.guard'; 5 | import { GoogleOauthGuard } from '../../guards/google-oauth.guard'; 6 | import { ScheduleComponent } from '../../shared/schedule/schedule.component'; 7 | import { PeriodSelectorComponent } from '../../shared/period-selector/period-selector.component'; 8 | import { ExportCalendarComponent } from '../../shared/export-calendar/export-calendar.component'; 9 | import { CalendarOptionsComponent } from '../../shared/export-calendar/components/calendar-options/calendar-options.component'; 10 | import { SubjectsSelectorComponent } from '../../shared/export-calendar/components/subjects-selector/subjects-selector.component'; 11 | import { HomeComponent } from './home.component'; 12 | 13 | const routes: Routes = [ 14 | { 15 | path: '', 16 | canActivate: [AuthGuard], 17 | component: HomeComponent, 18 | children: [ 19 | { 20 | path: '', 21 | redirectTo: 'schedule', 22 | pathMatch: 'full' 23 | }, 24 | { 25 | path: 'schedule', 26 | component: ScheduleComponent, 27 | canActivate: [ScheduleGuard] 28 | }, 29 | { 30 | path: 'period', 31 | component: PeriodSelectorComponent, 32 | }, 33 | { 34 | path: 'export', 35 | component: ExportCalendarComponent, 36 | children: [ 37 | { 38 | path: '', 39 | redirectTo: 'options', 40 | pathMatch: 'full' 41 | }, 42 | { 43 | path: 'options', 44 | component: CalendarOptionsComponent 45 | }, 46 | { 47 | path: 'select', 48 | component: SubjectsSelectorComponent, 49 | canActivate: [GoogleOauthGuard] 50 | }, 51 | ] 52 | }, 53 | ] 54 | } 55 | ]; 56 | 57 | @NgModule({ 58 | imports: [RouterModule.forChild(routes)], 59 | exports: [RouterModule] 60 | }) 61 | export class HomeRoutingModule { } 62 | -------------------------------------------------------------------------------- /client/src/app/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { tap } from 'rxjs/operators'; 3 | import { CookieService } from 'ngx-cookie-service'; 4 | import { HttpClient, HttpHeaders } from '@angular/common/http'; 5 | import { environment } from 'src/environments/environment'; 6 | import { USER_TOKEN_COOKIE, POMELO_DATA_COOKIE } from 'src/app/constants'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class AuthService { 12 | 13 | private API_URL = `${environment.apiUrl}/users`; 14 | private BASE_HEADER = new HttpHeaders({ 15 | 'Content-Type': 'application/json' 16 | }); 17 | 18 | constructor( 19 | private httpClient: HttpClient, 20 | private cookieService: CookieService, 21 | ) { } 22 | 23 | pomeloLogin(userCredentials: any) { 24 | return this.httpClient.post(`${this.API_URL}/login`, userCredentials, { 25 | headers: this.BASE_HEADER, 26 | }).pipe( 27 | tap( 28 | (res: any) => { 29 | // console.warn('userToken', res.data.token); 30 | this.cookieService.set( 31 | USER_TOKEN_COOKIE, 32 | res.data.token, 33 | environment.cookies.expires, 34 | environment.cookies.path, 35 | environment.cookies.domain, 36 | environment.cookies.secure, 37 | 'Strict', 38 | ); 39 | this.cookieService.set( 40 | POMELO_DATA_COOKIE, 41 | JSON.stringify(res.data.pomelo), 42 | environment.cookies.expires, 43 | environment.cookies.path, 44 | environment.cookies.domain, 45 | environment.cookies.secure, 46 | 'Strict', 47 | ); 48 | console.log('userToken', this.cookieService.get(USER_TOKEN_COOKIE)); 49 | console.log('pomeloData', JSON.parse(this.cookieService.get(POMELO_DATA_COOKIE))); 50 | }, 51 | err => console.error(err) 52 | ) 53 | ); 54 | } 55 | 56 | get token() { 57 | return this.cookieService.get(USER_TOKEN_COOKIE); 58 | } 59 | 60 | get pomeloData() { 61 | return JSON.parse(this.cookieService.get(POMELO_DATA_COOKIE)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/src/app/components/index/index.component.html: -------------------------------------------------------------------------------- 1 |
7 | 24 |
31 |

{{ title }}

32 |
33 |
34 | palette 35 |

Personaliza tu horario Uninorte.

36 |
37 |
38 | sync 39 |

Sincronízalo con Google Calendar.

40 |
41 |
42 | alarm 43 |

Recibe notificaciones.

44 |
45 |
46 | done_all 47 |

48 |

horarios fueron exportados.

49 |
50 |
51 |
52 |
58 |

Entrar

59 | arrow_right_alt 60 |
61 |
62 |
63 |
64 | Mi horario screens 70 |
71 |
72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **No longer maintained** 2 | 3 | > Lamentamos informarte que esta aplicación ha dejado de funcionar debido a cambios de seguridad implementados por la Universidad. 4 | 5 | # 📆 Mi horario UN 6 | 7 | Since 2020 more than 7,8k schedules have been imported to Google Calendar! 8 | 9 | 10 | 11 | 12 | 13 | image 14 | 15 | ## Disclaimer 16 | 17 | **This application is an open-source project, and it is not associated or officially supported by the Universidad del Norte.** 18 | 19 | ## Contribute 20 | 21 | Want to fix a bug, contribute, or improve documentation? Awesome! Check out the [guidelines](https://github.com/sjdonado/quevent/blob/master/CONTRIBUTING.md). 22 | 23 | ## How to run? 24 | 25 | 1. Create the oauth credentials/app [here](https://support.google.com/cloud/answer/6158849) 26 | 27 | 2. Run server 28 | 29 | ```bash 30 | bun dev 31 | ``` 32 | 33 | 3. Run client 34 | 35 | ```bash 36 | NODE_OPTIONS=--openssl-legacy-provider bun start 37 | ``` 38 | 39 | ## Inspiration 40 | 41 | First version was made by [krthr](https://github.com/krthr) 42 | -------------------------------------------------------------------------------- /client/src/app/components/help/help.component.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Ayuda 4 |

5 |
6 | 7 | 8 | arrow_back 9 | 10 | 11 |

1. Para iniciar sesión hacer click en Entrar, la cual conduce a la siguiente pantalla:

12 | Auth page 13 |

2. Después de introducir tus credenciales puedes seleccionar el periodo que deseas consultar:

14 | Girl in a jacket 15 |

3. Al hacer click en aceptar, podrás visualizar tu horario, puedes seleccionar ver sólo los lugares, descargarlo o exportarlo a Google Calendar 🕺🏽

16 | Girl in a jacket 17 |

4. Para visualizar los detalles de cada clase haz click sobre ella, en este panel puedes cambiar el color y el tiempo de notificación

18 | Girl in a jacket 19 |

5. Después de hacer click en Exportar es necesario iniciar sesión con Google para continuar (Si tienes dudas sobre la politica de privacidad no dudes en consultar aquí)

20 | Girl in a jacket 21 |

6. A continuación puedes seleccionar las materias que deseas importar, también devolverte al inicio para modificar el tiempo de nofiticación o el color de las mismas

22 | Girl in a jacket 23 |

7. ¡Listo! si tienes problemas, comentarios, deseas contribuir al proyecto o tienes una startup en mente contáctame al correo institucional 🙂

24 |
25 |
26 |
27 |
-------------------------------------------------------------------------------- /client/src/app/shared/dialogs/subject-details-dialog/subject-details-dialog.component.html: -------------------------------------------------------------------------------- 1 |

{{ subject.name }}

2 |
3 |
4 | check_circle 5 |

Sincronizado con el calendario de Google

6 |
7 |
8 |
10 | 11 | arrow_drop_down 12 |
13 | 14 | Tiempo de notificación 15 | 16 | 17 | {{ notificationTime.text }} 18 | 19 | 20 | 21 |
22 | 24 | 25 | label

NRC: {{ subject.nrc }}

26 |
27 | 28 | access_time

{{ subject.startTime }} - {{ subject.endTime }}

29 |
30 | 31 | place

{{ subject.place }}

32 |
33 | 34 | person

{{ subject.instructors }}

35 |
36 | 37 | info

{{ subject.type }}

38 |
39 |
40 |
41 | 42 | 43 |
44 | -------------------------------------------------------------------------------- /client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "array-type": false, 5 | "arrow-parens": false, 6 | "deprecation": { 7 | "severity": "warn" 8 | }, 9 | "component-class-suffix": true, 10 | "contextual-lifecycle": true, 11 | "directive-class-suffix": true, 12 | "directive-selector": [ 13 | true, 14 | "attribute", 15 | "app", 16 | "camelCase" 17 | ], 18 | "component-selector": [ 19 | true, 20 | "element", 21 | "app", 22 | "kebab-case" 23 | ], 24 | "import-blacklist": [ 25 | true, 26 | "rxjs/Rx" 27 | ], 28 | "interface-name": false, 29 | "max-classes-per-file": false, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-consecutive-blank-lines": false, 47 | "no-console": [ 48 | true, 49 | "debug", 50 | "info", 51 | "time", 52 | "timeEnd", 53 | "trace" 54 | ], 55 | "no-empty": false, 56 | "no-inferrable-types": [ 57 | true, 58 | "ignore-params" 59 | ], 60 | "no-non-null-assertion": true, 61 | "no-redundant-jsdoc": true, 62 | "no-switch-case-fall-through": true, 63 | "no-use-before-declare": true, 64 | "no-var-requires": false, 65 | "object-literal-key-quotes": [ 66 | true, 67 | "as-needed" 68 | ], 69 | "object-literal-sort-keys": false, 70 | "ordered-imports": false, 71 | "quotemark": [ 72 | true, 73 | "single" 74 | ], 75 | "trailing-comma": false, 76 | "no-conflicting-lifecycle": true, 77 | "no-host-metadata-property": true, 78 | "no-input-rename": true, 79 | "no-inputs-metadata-property": true, 80 | "no-output-native": true, 81 | "no-output-on-prefix": true, 82 | "no-output-rename": true, 83 | "no-outputs-metadata-property": true, 84 | "template-banana-in-box": true, 85 | "template-no-negated-async": true, 86 | "use-lifecycle-interface": true, 87 | "use-pipe-transform-interface": true 88 | }, 89 | "rulesDirectory": [ 90 | "codelyzer" 91 | ] 92 | } -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mi horario UN 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Mi horario UN 2 | - [Code of Conduct](#coc) 3 | - [Issues and Bugs](#issue) 4 | - [Submitting a Pull Request](#submit-pr) 5 | 6 | ## Code of Conduct 7 | Help us keep Angular open and inclusive. Please read and follow our [Code of Conduct][coc]. 8 | 9 | ## Found a Bug? 10 | If you find a bug in the source code, you can help us by 11 | submitting an issue to our [GitHub Repository][github]. Even better, you can 12 | [submit a Pull Request](#submit-pr) with a fix. 13 | 14 | ## Submitting a Pull Request (PR) 15 | 1. Select or create an issue 16 | 17 | 2. Fork the repo 18 | 19 | 3. Create a new branch using the following format `git checkout -b feature/#${ISSUE_NUMBER}-${ISSUE_TITLE}` 20 | 21 | ```shell 22 | # Example 23 | git checkout -b feature/#55-new-issue 24 | ``` 25 | 26 | 4. Use the issue references keywords [More information](https://help.github.com/en/github/managing-your-work-on-github/closing-issues-using-keywords#about-issue-references) 27 | 28 | ```shell 29 | # Example 30 | feat: New issue finished 31 | - Testing 32 | - Testing 33 | Resolve: #55 34 | ``` 35 | 5. In GitHub, send a pull request to `sjdonado/mihorario:master`. 36 | * If we suggest changes then: 37 | * Make the required updates. 38 | * Re-run the Angular test suites to ensure tests are still passing. 39 | * Rebase your branch and force push to your GitHub repository (this will update your Pull Request): 40 | 41 | ```shell 42 | git rebase master -i 43 | git push -f 44 | ``` 45 | 46 | 6. Rebase your branch and force push to your GitHub repository (this will update your Pull Request): 47 | 48 | ```shell 49 | git rebase master -i 50 | git push -f 51 | ``` 52 | 53 | ### That's it! Thank you for your contribution! 54 | 55 | #### After your pull request is merged 56 | After your pull request is merged, you can safely delete your branch and pull the changes 57 | from the main (upstream) repository: 58 | 59 | 7. Delete the remote branch on GitHub either through the GitHub web UI or your local shell as follows: 60 | 61 | ```shell 62 | git push origin --delete my-fix-branch 63 | ``` 64 | 65 | 8. Check out the master branch: 66 | 67 | ```shell 68 | git checkout master -f 69 | ``` 70 | 71 | 9. Delete the local branch: 72 | 73 | ```shell 74 | git branch -D my-fix-branch 75 | ``` 76 | 77 | 10. Update your master with the latest upstream version: 78 | 79 | ```shell 80 | git pull --ff upstream master 81 | 82 | [coc]: https://github.com/sjdonado/mihorario/blob/master/CODE_OF_CONDUCT.md 83 | [github]: https://github.com/sjdonado/mihorario -------------------------------------------------------------------------------- /client/src/app/shared/dialogs/subject-details-dialog/subject-details-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; 3 | import { Subject } from 'src/app/models/subject.model'; 4 | import { SubjectDetailsData } from 'src/app/models/subject-details-data.model'; 5 | import { GoogleCalendarService } from 'src/app/components/home/services/google-calendar.service'; 6 | import { EventColor } from 'src/app/models/event-color.model'; 7 | 8 | export interface EventNotificationTime { 9 | text: string; 10 | value: number; 11 | } 12 | 13 | @Component({ 14 | selector: 'app-subject-details-dialog', 15 | templateUrl: './subject-details-dialog.component.html', 16 | styleUrls: ['./subject-details-dialog.component.scss'] 17 | }) 18 | export class SubjectDetailsDialogComponent { 19 | 20 | private eventColors: string[]; 21 | private notificationTimeOptions: EventNotificationTime[]; 22 | public subject: Subject; 23 | private subjectEventColor: EventColor; 24 | private subjectNotificationTime: number; 25 | public isVisibleColorPicker = false; 26 | 27 | constructor( 28 | private googleCalendarService: GoogleCalendarService, 29 | public dialogRef: MatDialogRef, 30 | @Inject(MAT_DIALOG_DATA) public data: any 31 | ) { 32 | this.eventColors = this.googleCalendarService.eventColors.map(eventColor => eventColor.background); 33 | this.notificationTimeOptions = [{ 34 | text: 'Sin notificaciones', 35 | value: 0, 36 | }]; 37 | for (let i = 5; i <= 60; i += 5) { 38 | this.notificationTimeOptions.push({ 39 | text: `${i} minutos antes`, 40 | value: i, 41 | }); 42 | } 43 | this.subject = this.data.subject; 44 | console.log('=> SUBJECT', this.subject); 45 | this.subjectEventColor = this.data.subject.color; 46 | console.log('subjectNotificationTime', this.data.subject.notificationTime, 'subjectEventColor', this.subjectEventColor); 47 | this.subjectNotificationTime = this.data.subject.notificationTime; 48 | } 49 | 50 | colorPicker(event: any) { 51 | console.log('color', event.color.hex, 'event', event); 52 | if (event.$event.key && event.$event.key !== 'Enter') { 53 | return; 54 | } 55 | this.subjectEventColor = this.googleCalendarService.eventColors.find(eventColor => eventColor.background === event.color.hex); 56 | this.toggleColorPicker(); 57 | } 58 | 59 | toggleColorPicker() { 60 | this.isVisibleColorPicker = !this.isVisibleColorPicker; 61 | } 62 | 63 | get subjectDetailsData(): SubjectDetailsData { 64 | return { 65 | color: this.subjectEventColor, 66 | notificationTime: this.subjectNotificationTime, 67 | }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /client/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; 4 | import { environment } from '../environments/environment'; 5 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 6 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 7 | import { FlexLayoutModule } from '@angular/flex-layout'; 8 | 9 | import { MatCardModule } from '@angular/material/card'; 10 | import { MatFormFieldModule } from '@angular/material/form-field'; 11 | import { MatIconModule } from '@angular/material/icon'; 12 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 13 | import { MatInputModule } from '@angular/material/input'; 14 | import { MatButtonModule } from '@angular/material/button'; 15 | import { MatProgressBarModule } from '@angular/material/progress-bar'; 16 | 17 | import { AngularFireModule } from '@angular/fire'; 18 | import { AngularFireAuthModule } from '@angular/fire/auth'; 19 | import { AppRoutingModule } from './app-routing.module'; 20 | import { AppComponent } from './app.component'; 21 | import { LoginComponent } from './components/login/login.component'; 22 | 23 | import { AuthInterceptor } from './interceptors/auth.interceptor'; 24 | import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component'; 25 | import { IndexComponent } from './components/index/index.component'; 26 | import { HelpComponent } from './components/help/help.component'; 27 | 28 | import { CookieService } from 'ngx-cookie-service'; 29 | import { ServiceWorkerModule } from '@angular/service-worker'; 30 | 31 | 32 | @NgModule({ 33 | declarations: [ 34 | AppComponent, 35 | LoginComponent, 36 | PrivacyPolicyComponent, 37 | IndexComponent, 38 | HelpComponent, 39 | ], 40 | imports: [ 41 | BrowserModule, 42 | AppRoutingModule, 43 | HttpClientModule, 44 | FormsModule, 45 | ReactiveFormsModule, 46 | BrowserAnimationsModule, 47 | FlexLayoutModule, 48 | MatCardModule, 49 | MatFormFieldModule, 50 | MatIconModule, 51 | MatSnackBarModule, 52 | MatInputModule, 53 | MatButtonModule, 54 | MatProgressBarModule, 55 | AngularFireModule.initializeApp(environment.firebase, 'MiHorarioUN'), 56 | AngularFireAuthModule, 57 | ServiceWorkerModule.register('ngsw-worker.js', { 58 | enabled: environment.production, 59 | // Register the ServiceWorker as soon as the app is stable 60 | // or after 30 seconds (whichever comes first). 61 | registrationStrategy: 'registerWhenStable:30000' 62 | }) 63 | ], 64 | providers: [ 65 | { 66 | provide: HTTP_INTERCEPTORS, 67 | useClass: AuthInterceptor, 68 | multi: true 69 | }, 70 | CookieService, 71 | ], 72 | bootstrap: [AppComponent], 73 | }) 74 | export class AppModule { } 75 | -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/components/subjects-selector/subjects-selector.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | 7 | arrow_back 8 | 9 |
10 |

Seleccionar todo

11 |
12 |
13 |
14 | 15 |
16 |

{{ subject.shortName }}

17 |
18 |
19 | notifications 20 |

{{ subject.notificationTime }} minutos

21 |
22 |
23 | format_color_fill 24 | 25 |
26 |
27 | check_circle 28 | error 29 |
30 |
31 |
32 |
33 |
34 | more_vert 35 |
36 |
37 |
38 | 39 | 40 |
41 |
42 |
43 |
44 | 45 |
46 | 47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/components/calendar-options/calendar-options.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, NgZone } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { UserService } from 'src/app/components/home/services/user.service'; 4 | import { Subject } from 'src/app/models/subject.model'; 5 | import { GoogleCalendarService } from 'src/app/components/home/services/google-calendar.service'; 6 | import { NotificationService } from 'src/app/services/notification.service'; 7 | 8 | @Component({ 9 | selector: 'app-calendar-options', 10 | templateUrl: './calendar-options.component.html', 11 | styleUrls: ['./calendar-options.component.scss'] 12 | }) 13 | export class CalendarOptionsComponent implements OnInit { 14 | 15 | public googleOauthData: any; 16 | private subjectsByDays: Subject[][]; 17 | private subjects: Subject[]; 18 | 19 | constructor( 20 | private userService: UserService, 21 | private googleCalendarService: GoogleCalendarService, 22 | private notificationService: NotificationService, 23 | private router: Router, 24 | private ngZone: NgZone, 25 | ) { } 26 | 27 | ngOnInit() { 28 | this.googleOauthData = this.userService.googleOauthData; 29 | this.subjectsByDays = this.userService.subjectsByDays; 30 | this.subjects = this.googleCalendarService.getSubjects(this.subjectsByDays); 31 | } 32 | 33 | googleOauth() { 34 | if (this.userService.googleOauthData) { 35 | this.router.navigateByUrl('/home/export/select'); 36 | return; 37 | } 38 | this.userService.googleOauthLogin() 39 | .subscribe((oauthRes) => { 40 | if (oauthRes) { 41 | this.googleCalendarService.syncSchedule(this.subjects) 42 | .subscribe( 43 | (res: any) => { 44 | const { data } = res; 45 | this.subjectsByDays.forEach((day: Subject[]) => day.forEach((subject: Subject) => { 46 | const subjectRes = data.find(elem => elem.nrc === subject.nrc); 47 | subject.googleSynced = subjectRes.googleSynced; 48 | const color = this.googleCalendarService.eventColors 49 | .find(eventColor => eventColor.id === parseInt(subjectRes.color, 10)); 50 | subject.color = color ? color : subject.color; 51 | subject.notificationTime = subjectRes.notificationTime; 52 | })); 53 | this.userService.setSubjectsByDays(this.subjectsByDays); 54 | this.ngZone.run(() => { 55 | this.router.navigateByUrl('/home/export/select'); 56 | }); 57 | }, 58 | (err: any) => { 59 | this.notificationService.add('Error sincronizando tus materias.'); 60 | console.log('Error', err); 61 | } 62 | ); 63 | } 64 | }); 65 | } 66 | 67 | signInWithAnotherAccount() { 68 | this.userService.removeGoogleOauthData(); 69 | this.googleOauth(); 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /client/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js/dist/zone'; // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at twilson at uninorte.edu.co. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /client/src/app/components/home/home.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 5 | import { FlexLayoutModule } from '@angular/flex-layout'; 6 | 7 | import { MatSidenavModule } from '@angular/material/sidenav'; 8 | import { MatToolbarModule } from '@angular/material/toolbar'; 9 | import { MatListModule } from '@angular/material/list'; 10 | import { MatDividerModule } from '@angular/material/divider'; 11 | import { MatTableModule } from '@angular/material/table'; 12 | import { MatSelectModule } from '@angular/material/select'; 13 | import { MatDialogModule } from '@angular/material/dialog'; 14 | 15 | import { MatCardModule } from '@angular/material/card'; 16 | import { MatFormFieldModule } from '@angular/material/form-field'; 17 | import { MatIconModule } from '@angular/material/icon'; 18 | import { MatInputModule } from '@angular/material/input'; 19 | import { MatButtonModule } from '@angular/material/button'; 20 | 21 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 22 | import { MatMenuModule } from '@angular/material/menu'; 23 | import { MatCheckboxModule } from '@angular/material/checkbox'; 24 | import { ColorGithubModule } from 'ngx-color/github'; 25 | 26 | import { HomeRoutingModule } from './home-routing.module'; 27 | import { HomeComponent } from '../../components/home/home.component'; 28 | import { PeriodSelectorComponent } from '../../shared/period-selector/period-selector.component'; 29 | import { ScheduleComponent } from '../../shared/schedule/schedule.component'; 30 | import { ConfirmationDialogComponent } from '../../shared/dialogs/confirmation-dialog/confirmation-dialog.component'; 31 | import { ExportCalendarComponent } from '../../shared/export-calendar/export-calendar.component'; 32 | import { SubjectDetailsDialogComponent } from '../../shared/dialogs/subject-details-dialog/subject-details-dialog.component'; 33 | import { SubjectsSelectorComponent } from '../../shared/export-calendar/components/subjects-selector/subjects-selector.component'; 34 | import { CalendarOptionsComponent } from '../../shared/export-calendar/components/calendar-options/calendar-options.component'; 35 | 36 | import { CookieService } from 'ngx-cookie-service'; 37 | 38 | @NgModule({ 39 | declarations: [ 40 | HomeComponent, 41 | PeriodSelectorComponent, 42 | ScheduleComponent, 43 | ConfirmationDialogComponent, 44 | ExportCalendarComponent, 45 | SubjectDetailsDialogComponent, 46 | SubjectsSelectorComponent, 47 | CalendarOptionsComponent, 48 | ], 49 | imports: [ 50 | CommonModule, 51 | HomeRoutingModule, 52 | FormsModule, 53 | ReactiveFormsModule, 54 | FlexLayoutModule, 55 | MatProgressSpinnerModule, 56 | MatSidenavModule, 57 | MatToolbarModule, 58 | MatListModule, 59 | MatDividerModule, 60 | MatTableModule, 61 | MatSelectModule, 62 | MatDialogModule, 63 | MatMenuModule, 64 | MatCheckboxModule, 65 | MatCardModule, 66 | MatFormFieldModule, 67 | MatIconModule, 68 | MatInputModule, 69 | MatButtonModule, 70 | ColorGithubModule, 71 | ], 72 | entryComponents: [ 73 | ConfirmationDialogComponent, 74 | SubjectDetailsDialogComponent 75 | ], 76 | providers: [ 77 | CookieService, 78 | ], 79 | }) 80 | export class HomeModule { } 81 | -------------------------------------------------------------------------------- /client/src/app/components/privacy-policy/privacy-policy.component.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Política de privacidad 4 |

5 |
6 | 7 | 8 | arrow_back 9 | 10 | 11 |

12 | Mi horario UN es una aplicación de código abierto sin ánimo de lucro con licencia GNU General Public License v3.0 13 | (Más información). 14 |

15 |

16 | El código fuente de Mi horario UN está alojado en Github y es de libre acceso https://github.com/sjdonado/mihorario. 17 |

18 |

Mi horario UN NO está asociado directamente con la Universidad del Norte y accede a la información del usuario a través de canales oficiales con las credenciales del usuario.

19 |

Mi horario UN hace uso de una arquitectura serverless (reduce costos de operación). Para la comunicación entre el cliente y AWS se emplea autentificación por 20 | JWT con expiración por cada 24 horas. Cada lambda function está protegida con CORS 21 | (más información).

22 |

Mi horario UN NO almacena información del usuario en el servidor, no posee base de datos y es desplegada en la nube automáticamente con Github Actions.

23 |

Mi horario UN almacena el token de autenticación del usuario en el navegador a través de cookies con tiempo de expiración de 24 horas, al usuario cerrar sesión TODA su información es eliminada inmediatamente.

24 |

Mi horario UN necesita la autorización del usuario para poder ver, modificar y crear eventos en su calendario de google (scope: https://www.googleapis.com/auth/calendar.events más información), 25 | los cuales son creados o editados ÚNICAMENTE si este está relacionado por el nombre con alguna materia del horario del usuario para el periodo seleccionado por el mismo.

26 |

Mi horario UN NO hace uso de cookies para almacenar información distinta a la ingresada por el usuario.

27 |

Al importar el horario a Google Calendar, Firebase almacena los permisos otorgados al correo electrónico usado. Mi horario UN NO utilizará esta información para ningún fin diferente a los dispuestos en http://mihorarioun.web.app/.

28 |

Como estudiantes tenemos la oportunidad de proponer cambios, de servir a la sociedad, una línea de código a la vez.

29 |
30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /client/src/styles.scss: -------------------------------------------------------------------------------- 1 | @import '@angular/material/prebuilt-themes/indigo-pink.css'; 2 | @import 'theme.scss'; 3 | 4 | html, body { 5 | background: transparent; 6 | height: 100vh; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | body { 12 | font-family: Roboto, 'Helvetica Neue', sans-serif; 13 | } 14 | 15 | * { 16 | box-sizing: border-box; 17 | &:focus { 18 | outline: 0; 19 | } 20 | } 21 | 22 | .mat-form-field-appearance-outline .mat-form-field-infix { 23 | padding: 1em 6px 1em 0; 24 | } 25 | 26 | mat-card-content{ 27 | overflow: auto; 28 | max-height: 100%; 29 | } 30 | 31 | div.card-title{ 32 | font-size: 22px; 33 | font-weight: 500; 34 | margin-bottom: 8px; 35 | } 36 | 37 | .mat-card{ 38 | display: flex !important; 39 | flex-direction: column; 40 | max-height: 100%; 41 | } 42 | 43 | div.floating-buttons { 44 | position: fixed; 45 | bottom: 20px; 46 | right: 20px; 47 | z-index: 999; 48 | } 49 | 50 | table.selectable-row { 51 | tbody { 52 | tr { 53 | cursor: pointer; 54 | &:hover, &.active { 55 | background-color: rgb(247,247,247); 56 | } 57 | } 58 | } 59 | } 60 | 61 | /* You will be forced to separate it from the mat progress bar logic */ 62 | mat-progress-bar.white .mat-progress-bar-fill::after{ 63 | background-color: white; 64 | } 65 | 66 | mat-progress-bar.white { 67 | .mat-progress-bar-buffer { 68 | background-color: #585858; 69 | } 70 | } 71 | 72 | mat-form-field.select-search-input { 73 | padding: 16px 16px 0 16px; 74 | width: 100%; 75 | } 76 | 77 | div.mat-dialog-actions { 78 | place-content: center flex-end; 79 | } 80 | 81 | div.photo-preview { 82 | text-align: center; 83 | > img { 84 | max-width: 200px; 85 | } 86 | } 87 | 88 | // Custom styles 89 | 90 | div.wrapper { 91 | width: 100%; 92 | height: 95%; 93 | background: linear-gradient(to bottom, rgb(96, 161, 235), rgba(172, 215, 250, 0.4) 87%, rgba(white, .5)); 94 | } 95 | 96 | mat-card { 97 | box-shadow: 0 2px 30px rgba(black, .2) !important; 98 | } 99 | 100 | p { 101 | letter-spacing: 0.2em; 102 | text-indent: 0.3em; 103 | } 104 | 105 | .option { 106 | color: white; 107 | text-transform: uppercase; 108 | text-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); 109 | text-align: center; 110 | vertical-align: middle; 111 | } 112 | 113 | h1 { 114 | color: white; 115 | text-transform: uppercase; 116 | letter-spacing: 0.4em !important; 117 | text-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); 118 | text-indent: 0.3em; 119 | z-index: 1; 120 | font-weight: normal !important; 121 | &.header { 122 | font-size: 20px; 123 | text-align: center; 124 | margin-bottom: 40px; 125 | } 126 | } 127 | 128 | 129 | .mat-progress-spinner circle, .mat-spinner circle { 130 | stroke: white; 131 | } 132 | 133 | .go-back-button { 134 | cursor: pointer; 135 | color: #333; 136 | margin-bottom: 20px; 137 | } 138 | 139 | button { 140 | text-transform: uppercase; 141 | letter-spacing: 0.2em; 142 | } 143 | 144 | div.button { 145 | cursor: pointer; 146 | line-height: 36px; 147 | padding: 0 16px; 148 | border-radius: 4px; 149 | p { 150 | text-transform: uppercase; 151 | text-align: center; 152 | font-weight: 500; 153 | } 154 | &:hover { 155 | background: #fafafa; 156 | } 157 | } 158 | 159 | .page-title { 160 | font-size: 22px; 161 | text-align: center; 162 | } 163 | 164 | mat-icon.checked { 165 | color: green; 166 | } 167 | 168 | mat-icon.warning { 169 | color: orange; 170 | } -------------------------------------------------------------------------------- /client/src/app/shared/schedule/schedule.component.scss: -------------------------------------------------------------------------------- 1 | $table-border: rgb(221, 221, 221); 2 | $text-color: #333; 3 | $row-bg: #f6f6f6; 4 | 5 | .wrapper { 6 | display: block; 7 | margin: auto; 8 | width: 95%; 9 | height: 95%; 10 | background: transparent; 11 | span { 12 | color: $text-color; 13 | } 14 | } 15 | 16 | .card-header { 17 | font-size: 12px; 18 | margin-bottom: 10px; 19 | } 20 | 21 | .option { 22 | cursor: pointer; 23 | mat-icon { 24 | margin-left: 2px; 25 | } 26 | } 27 | 28 | .table-container { 29 | display: table; 30 | width: 100%; 31 | margin: auto; 32 | font-size: 0.76em; 33 | } 34 | 35 | .flag-icon { 36 | margin-right: 0.1em; 37 | } 38 | 39 | .flex-table { 40 | display: flex; 41 | flex-flow: row wrap; 42 | border-left: solid 1px $table-border; 43 | transition: 0.5s; 44 | &:first-of-type { 45 | border-top: solid 1px $table-border; 46 | border-left: solid 1px $table-border; 47 | } 48 | &:first-of-type .flex-row { 49 | background: transparent; 50 | color: $text-color; 51 | border-color: $table-border; 52 | } 53 | &.row:nth-child(even) .flex-row { 54 | background: $row-bg; 55 | } 56 | } 57 | 58 | .flex-row { 59 | width: calc(106.62% / 7); 60 | margin: auto; 61 | text-align: center; 62 | vertical-align: middle; 63 | background: white; 64 | border-right: solid 1px $table-border; 65 | border-bottom: solid 1px $table-border; 66 | p { 67 | cursor: pointer; 68 | white-space: nowrap; 69 | overflow: hidden; 70 | text-overflow: ellipsis; 71 | } 72 | } 73 | 74 | .flex-text { 75 | cursor: pointer; 76 | padding: 0 2px; 77 | &-left { 78 | border-right: 1px solid $table-border; 79 | } 80 | } 81 | 82 | .hour { 83 | font-weight: bold; 84 | width: calc(60% / 7); 85 | } 86 | 87 | .header { 88 | font-weight: bold; 89 | } 90 | 91 | .rowspan { 92 | display: flex; 93 | flex-flow: row wrap; 94 | align-items: flex-start; 95 | justify-content: center; 96 | } 97 | 98 | .column { 99 | display: flex; 100 | flex-flow: column wrap; 101 | width: 75%; 102 | padding: 0; 103 | .flex-row { 104 | display: flex; 105 | flex-flow: row wrap; 106 | width: 100%; 107 | padding: 0; 108 | border: 0; 109 | border-bottom: solid 1px $table-border; 110 | } 111 | } 112 | 113 | .flex-cell { 114 | width: calc(100% / 6); //1px = border right 115 | text-align: center; 116 | padding: 0.5em 0.5em; 117 | border-right: solid 1px $table-border; 118 | } 119 | 120 | @media all and (max-width: 767px) { 121 | .flex-row { 122 | width: calc(100% / 3); //1px = border right 123 | border-bottom: solid 1px $table-border; 124 | &.first { 125 | width: 100%; 126 | // border-top: solid 1px $table-border; 127 | } 128 | } 129 | .column { 130 | width: 100%; 131 | } 132 | } 133 | 134 | @media all and (max-width: 430px) { 135 | .flex-table { 136 | .flex-row:last-of-type { 137 | border-bottom: solid 1px $table-border; 138 | } 139 | } 140 | .header { 141 | .flex-row { 142 | border-bottom: solid 1px; 143 | } 144 | } 145 | .flex-row { 146 | width: 100%; //1px = border right 147 | 148 | &.first { 149 | width: 100%; 150 | border-bottom: solid 1px $table-border; 151 | } 152 | } 153 | .column { 154 | width: 100%; 155 | .flex-row { 156 | border-bottom: solid 1px $table-border; 157 | } 158 | } 159 | .flex-cell { 160 | width: 100%; //1px = border right 161 | } 162 | } -------------------------------------------------------------------------------- /server/src/api/v1/users/model.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | 3 | const PomeloService = require('../../../services/pomelo'); 4 | 5 | /** 6 | * Get pomelo userId 7 | * @param {{ username: String, password: String }} credentials 8 | */ 9 | const pomeloUserId = (credentials) => { 10 | const client = new PomeloService(credentials); 11 | return client.getUserId(); 12 | }; 13 | 14 | /** 15 | * Get pomelo schedule terms 16 | * @param {{ username: String, password: String }} credentials 17 | * @param {String} userId 18 | */ 19 | const pomeloScheduleTerms = async (credentials, userId) => { 20 | const client = new PomeloService(credentials); 21 | return client.getFullNameAndTerms(userId); 22 | }; 23 | 24 | /** 25 | * Get pomelo schedule 26 | * @param {{ username: String, password: String }} credentials 27 | * @param {String} scheduleOption 28 | */ 29 | const pomeloSchedule = async (credentials, userId, termId) => { 30 | const client = new PomeloService(credentials); 31 | const data = await client.getSchedule(userId, termId); 32 | 33 | const subjectsByDays = [[], [], [], [], [], [], []]; 34 | data.forEach(({ 35 | courseName, 36 | sectionId, 37 | sectionTitle, 38 | instructors, 39 | meetingPatterns, 40 | }) => { 41 | meetingPatterns.forEach(({ 42 | buildingId, 43 | room, 44 | startDate, 45 | endDate, 46 | daysOfWeek, 47 | sisStartTimeWTz, 48 | sisEndTimeWTz, 49 | }) => { 50 | const parsedStartTime = moment.parseZone(sisStartTimeWTz, 'HH:mm'); 51 | const parsedEndTime = moment(sisEndTimeWTz, 'HH:mm'); 52 | const startDateParsed = moment(startDate); 53 | const endDateParsed = moment(endDate); 54 | const weekdayParsed = (daysOfWeek[0] + 5) % 7; 55 | 56 | if (startDateParsed.month() !== endDateParsed.month()) { 57 | subjectsByDays[weekdayParsed].push({ 58 | nrc: sectionId, 59 | name: sectionTitle, 60 | shortName: sectionTitle, 61 | instructors: instructors.map(({ formattedName }) => formattedName).join(','), 62 | type: courseName, 63 | place: `${buildingId} ${room}`, 64 | startDate: startDateParsed.format('MMM DD, YYYY', 'es'), 65 | endDate: endDateParsed.format('MMM DD, YYYY', 'es'), 66 | startTime: parsedStartTime.format('hh:mm A'), 67 | endTime: parsedEndTime.format('hh:mm A'), 68 | }); 69 | } 70 | }); 71 | }); 72 | 73 | const scheduleByHours = Array.from(Array(18), () => new Array(6)); 74 | subjectsByDays.forEach((day, index) => { 75 | day.forEach((row) => { 76 | const startSubjectDate = moment(row.startTime, 'hh:mm A'); 77 | const endSubjectDate = moment(row.endTime, 'hh:mm A'); 78 | 79 | let startSubjectInt = parseInt(startSubjectDate.hours(), 10); 80 | if (startSubjectInt < 6) startSubjectInt = 6; 81 | const endSubjectInt = parseInt(endSubjectDate.hours(), 10); 82 | 83 | while (endSubjectInt - startSubjectInt >= 1) { 84 | scheduleByHours[startSubjectInt - 6][index] = { 85 | ...row, 86 | startParsedTime: `${startSubjectInt}:${startSubjectDate.minutes()}`, 87 | endParsedTime: `${startSubjectInt + 1}:${endSubjectDate.minutes()}`, 88 | startDate: row.startDate, 89 | endDate: row.endDate, 90 | }; 91 | startSubjectInt += 1; 92 | } 93 | }); 94 | }); 95 | 96 | return { scheduleByHours, subjectsByDays }; 97 | }; 98 | 99 | module.exports = { 100 | pomeloUserId, 101 | pomeloScheduleTerms, 102 | pomeloSchedule, 103 | }; 104 | -------------------------------------------------------------------------------- /client/src/app/components/home/services/google-calendar.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CookieService } from 'ngx-cookie-service'; 3 | import { HttpClient, HttpHeaders } from '@angular/common/http'; 4 | import { environment } from 'src/environments/environment'; 5 | import { Subject } from '../../../models/subject.model'; 6 | import { 7 | USER_TOKEN_COOKIE, 8 | GOOGLE_OAUTH_DATA_KEY, 9 | } from 'src/app/constants'; 10 | 11 | @Injectable({ 12 | providedIn: 'root' 13 | }) 14 | export class GoogleCalendarService { 15 | 16 | private API_URL = `${environment.apiUrl}/google-calendar`; 17 | private BASE_HEADER: HttpHeaders; 18 | private EVENT_COLORS = [ 19 | { id: 0, 20 | background: '#ffffff', 21 | foreground: '#1d1d1d' 22 | }, 23 | { 24 | id: 1, 25 | background: '#a4bdfc', 26 | foreground: '#1d1d1d' 27 | }, 28 | { 29 | id: 2, 30 | background: '#7ae7bf', 31 | foreground: '#1d1d1d' 32 | }, 33 | { 34 | id: 3, 35 | background: '#dbadff', 36 | foreground: '#1d1d1d' 37 | }, 38 | { 39 | id: 4, 40 | background: '#ff887c', 41 | foreground: '#1d1d1d' 42 | }, 43 | { 44 | id: 5, 45 | background: '#fbd75b', 46 | foreground: '#1d1d1d' 47 | }, 48 | { 49 | id: 6, 50 | background: '#ffb878', 51 | foreground: '#1d1d1d' 52 | }, 53 | { 54 | id: 7, 55 | background: '#46d6db', 56 | foreground: '#1d1d1d' 57 | }, 58 | { 59 | id: 8, 60 | background: '#e1e1e1', 61 | foreground: '#1d1d1d' 62 | }, 63 | { 64 | id: 9, 65 | background: '#5484ed', 66 | foreground: '#1d1d1d' 67 | }, 68 | { 69 | id: 10, 70 | background: '#51b749', 71 | foreground: '#1d1d1d' 72 | }, 73 | { 74 | id: 11, 75 | background: '#dc2127', 76 | foreground: '#1d1d1d' 77 | }, 78 | ]; 79 | 80 | constructor( 81 | private httpClient: HttpClient, 82 | private cookieService: CookieService, 83 | ) { 84 | this.BASE_HEADER = new HttpHeaders({ 85 | 'Content-Type': 'application/json', 86 | authorization: this.cookieService.get(USER_TOKEN_COOKIE), 87 | }); 88 | } 89 | 90 | importSubjects(subjectsMatrix: Subject[][]) { 91 | const googleOauthData = JSON.parse(this.cookieService.get(GOOGLE_OAUTH_DATA_KEY)); 92 | return this.httpClient.post(`${this.API_URL}/import`, Object.assign({ subjectsMatrix }, googleOauthData), { 93 | headers: this.BASE_HEADER, 94 | }); 95 | } 96 | 97 | removeSubjects(subjects: Subject[]) { 98 | const googleOauthData = JSON.parse(this.cookieService.get(GOOGLE_OAUTH_DATA_KEY)); 99 | return this.httpClient.post(`${this.API_URL}/remove`, Object.assign({ subjects }, googleOauthData), { 100 | headers: this.BASE_HEADER, 101 | }); 102 | } 103 | 104 | syncSchedule(subjects: Subject[]) { 105 | const googleOauthData = JSON.parse(this.cookieService.get(GOOGLE_OAUTH_DATA_KEY)); 106 | return this.httpClient.post(`${this.API_URL}/sync`, Object.assign({ subjects }, googleOauthData), { 107 | headers: this.BASE_HEADER, 108 | }); 109 | } 110 | 111 | getSubjects(subjectsByDays: Subject[][]) { 112 | const subjects = []; 113 | subjectsByDays.forEach((day: Subject[]) => day.forEach((subject: Subject) => { 114 | if (!subjects.find(elem => elem.nrc === subject.nrc)) { 115 | subjects.push(subject); 116 | } 117 | })); 118 | return subjects; 119 | } 120 | 121 | get eventColors() { 122 | return this.EVENT_COLORS; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /server/src/api/v1/googleCalendar/model.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const { parsePomeloDateToCalendar } = require('../../../lib/Date'); 3 | 4 | const CalendarService = require('../../../services/calendar'); 5 | 6 | const getSyncedSubjects = async (tokens, subjects) => { 7 | const calendarService = new CalendarService(tokens); 8 | return calendarService.getSyncedScheduleEvents(subjects); 9 | }; 10 | 11 | /** 12 | * Import Pomelo schedule to Google calendar 13 | * @param {Object} tokens 14 | * @param {Object} tokens.access_token 15 | * @param {Object} tokens.refresh_token 16 | * @param {Object[][]} subjectsMatrix 17 | */ 18 | const importSchedule = async (tokens, subjectsMatrix) => { 19 | const calendarService = new CalendarService(tokens); 20 | const events = []; 21 | /** 22 | * With the Promise.all implementation some request are executing at same time causing a server 23 | * error response. 24 | * Use for loops avoid this problem. 25 | */ 26 | // eslint-disable-next-line no-restricted-syntax 27 | for (const [dayNumber, day] of subjectsMatrix.entries()) { 28 | // eslint-disable-next-line no-restricted-syntax 29 | for (const subject of day) { 30 | // Verify if is already synced 31 | if (!subject.googleSynced) { 32 | // Calendar day --- dayNumber 33 | // S M T W T F S --- S M T W T F S 34 | // 1 2 3 4 5 6 7 --- 6 0 1 2 3 4 5 35 | const startDate = moment(subject.startDate, 'MMM DD, YYYY'); 36 | const endDate = moment(subject.endDate, 'MMM DD, YYYY'); 37 | 38 | const firstWeekDay = startDate.clone().startOf('week'); 39 | 40 | const startDateTime = moment(`${firstWeekDay.format('YYYYMMDD')} ${subject.startTime} -05:00`, 'YYYYMMDD hh:mm A Z'); 41 | const endDateTime = moment(`${firstWeekDay.format('YYYYMMDD')} ${subject.endTime} -05:00`, 'YYYYMMDD hh:mm A Z'); 42 | 43 | const recurrence = []; 44 | if (startDate.format('YYYYMMDD') !== endDate.format('YYYYMMDD')) { 45 | recurrence.push(`RRULE:FREQ=WEEKLY;UNTIL=${endDate.format('YYYYMMDD')}`); 46 | } 47 | 48 | const calendarEventData = { 49 | location: subject.place, 50 | summary: subject.name, 51 | description: subject.instructors, 52 | start: parsePomeloDateToCalendar(startDateTime.add(dayNumber + 1, 'days')), 53 | end: parsePomeloDateToCalendar(endDateTime.add(dayNumber + 1, 'days')), 54 | reminders: { 55 | useDefault: false, 56 | overrides: [ 57 | { 58 | method: 'popup', 59 | minutes: subject.notificationTime, 60 | }, 61 | ], 62 | }, 63 | recurrence, 64 | }; 65 | 66 | if (subject.colorId !== 0) Object.assign(calendarEventData, { colorId: subject.colorId }); 67 | 68 | // eslint-disable-next-line no-await-in-loop 69 | const calendarEvent = await calendarService.createEvent(calendarEventData); 70 | events.push(Object.assign(calendarEvent, { data: { subject } })); 71 | } 72 | } 73 | } 74 | return events; 75 | }; 76 | 77 | /** 78 | * Remove Pomelo subjects from Google calendar 79 | * @param {Object} tokens 80 | * @param {Object} tokens.access_token 81 | * @param {Object} tokens.refresh_token 82 | * @param {Object[]} subjects 83 | */ 84 | const removeSubjects = async (tokens, subjects) => { 85 | const calendarService = new CalendarService(tokens); 86 | const response = []; 87 | 88 | const allSyncedEvents = await calendarService.getAllSyncedEvents(subjects); 89 | await Promise.all(allSyncedEvents.map(async ({ subject, eventId }) => { 90 | const { error } = await calendarService.deleteEvent(eventId); 91 | response.push(Object.assign(subject, { googleSynced: typeof error === 'undefined' })); 92 | })); 93 | 94 | return response; 95 | }; 96 | 97 | module.exports = { 98 | getSyncedSubjects, 99 | importSchedule, 100 | removeSubjects, 101 | }; 102 | -------------------------------------------------------------------------------- /client/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ui": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/client", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": "src/polyfills.ts", 24 | "tsConfig": "tsconfig.app.json", 25 | "assets": [ 26 | "src/favicon.ico", 27 | "src/assets", 28 | "src/manifest.webmanifest" 29 | ], 30 | "styles": [ 31 | { 32 | "input": "src/theme.scss" 33 | }, 34 | "src/styles.scss" 35 | ], 36 | "scripts": [], 37 | "serviceWorker": true, 38 | "ngswConfigPath": "ngsw-config.json" 39 | }, 40 | "configurations": { 41 | "production": { 42 | "fileReplacements": [ 43 | { 44 | "replace": "src/environments/environment.ts", 45 | "with": "src/environments/environment.prod.ts" 46 | } 47 | ], 48 | "optimization": true, 49 | "outputHashing": "all", 50 | "sourceMap": false, 51 | "namedChunks": false, 52 | "aot": true, 53 | "extractLicenses": true, 54 | "vendorChunk": false, 55 | "buildOptimizer": true, 56 | "budgets": [ 57 | { 58 | "type": "initial", 59 | "maximumWarning": "2mb", 60 | "maximumError": "5mb" 61 | } 62 | ] 63 | } 64 | } 65 | }, 66 | "serve": { 67 | "builder": "@angular-devkit/build-angular:dev-server", 68 | "options": { 69 | "browserTarget": "ui:build" 70 | }, 71 | "configurations": { 72 | "production": { 73 | "browserTarget": "ui:build:production" 74 | } 75 | } 76 | }, 77 | "extract-i18n": { 78 | "builder": "@angular-devkit/build-angular:extract-i18n", 79 | "options": { 80 | "browserTarget": "ui:build" 81 | } 82 | }, 83 | "test": { 84 | "builder": "@angular-devkit/build-angular:karma", 85 | "options": { 86 | "main": "src/test.ts", 87 | "polyfills": "src/polyfills.ts", 88 | "tsConfig": "tsconfig.spec.json", 89 | "karmaConfig": "karma.conf.js", 90 | "assets": [ 91 | "src/favicon.ico", 92 | "src/assets", 93 | "src/manifest.webmanifest" 94 | ], 95 | "styles": [ 96 | "src/styles.scss" 97 | ], 98 | "scripts": [] 99 | } 100 | }, 101 | "lint": { 102 | "builder": "@angular-devkit/build-angular:tslint", 103 | "options": { 104 | "tsConfig": [ 105 | "tsconfig.app.json", 106 | "tsconfig.spec.json", 107 | "e2e/tsconfig.json" 108 | ], 109 | "exclude": [ 110 | "**/node_modules/**" 111 | ] 112 | } 113 | }, 114 | "e2e": { 115 | "builder": "@angular-devkit/build-angular:protractor", 116 | "options": { 117 | "protractorConfig": "e2e/protractor.conf.js", 118 | "devServerTarget": "ui:serve" 119 | }, 120 | "configurations": { 121 | "production": { 122 | "devServerTarget": "ui:serve:production" 123 | } 124 | } 125 | }, 126 | "deploy": { 127 | "builder": "@angular/fire:deploy", 128 | "options": {} 129 | } 130 | } 131 | } 132 | }, 133 | "defaultProject": "ui" 134 | } -------------------------------------------------------------------------------- /client/src/app/shared/schedule/schedule.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 |

Opciones

arrow_drop_down 7 |
8 | 9 | 13 | 16 | 19 | 20 |
21 |
22 | 24 |

{{ linkOption.title }}

{{ linkOption.icon }} 25 |
26 | 28 |

{{ clickOption.title }}

{{ clickOption.icon }} 29 |
30 | 31 |

Ver lugares

{{ isLocationView ? 'toggle_on' : 'toggle_off' }} 32 |
33 |
34 |
35 | 36 |
37 |
38 |

HORA

39 |
40 | 41 |

{{ days[i] }}

42 |
43 | 44 |
45 |
46 |

{{ days[5] }}

47 |
48 |
49 |

{{ days[6] }}

50 |
51 |
52 |
53 |
54 |
55 |
56 |

{{ i + 6 }}:30-{{ i + 7 }}:29

57 |
59 | 60 |

61 | {{ schedule[i][j] ? (isLocationView ? schedule[i][j].place : schedule[i][j].shortName) : '​' }} 62 |

63 |
64 | 65 |
66 |
67 |

{{ schedule[i][j] ? (isLocationView ? schedule[i][j].place : schedule[i][j].shortName) : '​' }}

69 |
70 |
71 |

{{ schedule[i][6] ? (isLocationView ? schedule[i][6].place : schedule[i][6].shortName) : '​' }}

73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
-------------------------------------------------------------------------------- /client/src/app/components/home/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import { Injectable } from '@angular/core'; 3 | import { CookieService } from 'ngx-cookie-service'; 4 | import { tap, map } from 'rxjs/operators'; 5 | import { HttpClient, HttpHeaders } from '@angular/common/http'; 6 | import { AngularFireAuth } from '@angular/fire/auth'; 7 | import { environment } from 'src/environments/environment'; 8 | import { from } from 'rxjs'; 9 | 10 | import { Subject } from '../../../models/subject.model'; 11 | import { GoogleCalendarService } from './google-calendar.service'; 12 | import { 13 | USER_TOKEN_COOKIE, 14 | SCHEDULE_BY_HOURS_KEY, 15 | SUBJECTS_BY_DAYS_KEY, 16 | GOOGLE_OAUTH_DATA_KEY, 17 | } from 'src/app/constants'; 18 | 19 | interface GoogleOauthData { 20 | email: string; 21 | accessToken: string; 22 | refreshToken: string; 23 | } 24 | 25 | @Injectable({ 26 | providedIn: 'root' 27 | }) 28 | export class UserService { 29 | 30 | private API_URL = `${environment.apiUrl}/users`; 31 | private BASE_HEADER: HttpHeaders; 32 | 33 | constructor( 34 | private httpClient: HttpClient, 35 | private afAuth: AngularFireAuth, 36 | private googleCalendarService: GoogleCalendarService, 37 | private cookieService: CookieService, 38 | ) { 39 | this.BASE_HEADER = new HttpHeaders({ 40 | 'Content-Type': 'application/json', 41 | authorization: this.cookieService.get(USER_TOKEN_COOKIE), 42 | }); 43 | } 44 | 45 | getSchedule(termId: string) { 46 | return this.httpClient.get(`${this.API_URL}/schedule?termId=${termId}`, { 47 | headers: this.BASE_HEADER, 48 | }).pipe( 49 | tap( 50 | (res: any) => { 51 | let currentStyle; 52 | let styleColorIdx = 0; 53 | const styles = new Map(); 54 | this.setScheduleByHours(res.data.scheduleByHours.map((hours: Subject[]) => hours.map(subject => { 55 | if (!subject) { 56 | return subject; 57 | } 58 | if (styles.has(subject.nrc)) { 59 | currentStyle = styles.get(subject.nrc); 60 | } else { 61 | currentStyle = { 62 | color: this.googleCalendarService.eventColors[styleColorIdx + 1], 63 | notificationTime: 15, 64 | }; 65 | styles.set(subject.nrc, currentStyle); 66 | styleColorIdx++; 67 | } 68 | return Object.assign(subject, currentStyle); 69 | }))); 70 | 71 | this.setSubjectsByDays(res.data.subjectsByDays 72 | .map((day: Subject[]) => day.map(subject => Object.assign(subject, styles.get(subject.nrc))))); 73 | }, 74 | err => console.error(err), 75 | ) 76 | ); 77 | } 78 | 79 | googleOauthLogin() { 80 | const provider = new firebase.auth.GoogleAuthProvider(); 81 | provider.addScope('https://www.googleapis.com/auth/calendar.events'); 82 | return from(this.afAuth.signInWithPopup(provider)) 83 | .pipe(map( 84 | res => { 85 | console.log('googleOauthLogin', res); 86 | this.setGoogleOauthData({ 87 | email: res.user.email, 88 | // tslint:disable-next-line: no-string-literal 89 | accessToken: res.credential['accessToken'], 90 | refreshToken: res.user.refreshToken, 91 | }); 92 | return true; 93 | // return this.googleLogin(googleOauthTokens, res.user.email); 94 | }, 95 | err => console.error(err) 96 | )); 97 | } 98 | 99 | get scheduleByHours() { 100 | return JSON.parse(localStorage.getItem(SCHEDULE_BY_HOURS_KEY)); 101 | } 102 | 103 | setScheduleByHours(schedule: Subject[][]) { 104 | localStorage.setItem(SCHEDULE_BY_HOURS_KEY, JSON.stringify(schedule)); 105 | } 106 | 107 | get subjectsByDays() { 108 | return JSON.parse(localStorage.getItem(SUBJECTS_BY_DAYS_KEY)); 109 | } 110 | 111 | setSubjectsByDays(subjectsByDays: Subject[][]) { 112 | localStorage.setItem(SUBJECTS_BY_DAYS_KEY, JSON.stringify(subjectsByDays)); 113 | } 114 | 115 | setGoogleOauthData(googleOauthData: GoogleOauthData) { 116 | this.cookieService.set( 117 | GOOGLE_OAUTH_DATA_KEY, 118 | JSON.stringify(googleOauthData), 119 | environment.cookies.expires, 120 | environment.cookies.path, 121 | environment.cookies.domain, 122 | environment.cookies.secure, 123 | 'Strict', 124 | ); 125 | } 126 | 127 | get googleOauthData() { 128 | return this.cookieService.check(GOOGLE_OAUTH_DATA_KEY) ? JSON.parse(this.cookieService.get(GOOGLE_OAUTH_DATA_KEY)) : null; 129 | } 130 | 131 | removeGoogleOauthData() { 132 | this.cookieService.delete(GOOGLE_OAUTH_DATA_KEY, environment.cookies.path, environment.cookies.domain); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /client/src/app/shared/schedule/schedule.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { MatDialog } from '@angular/material/dialog'; 3 | import { Subject } from '../../models/subject.model'; 4 | import { SubjectDetailsDialogComponent } from '../dialogs/subject-details-dialog/subject-details-dialog.component'; 5 | import { UserService } from 'src/app/components/home/services/user.service'; 6 | import { AuthService } from 'src/app/services/auth.service'; 7 | import html2canvas from 'html2canvas'; 8 | import { EventColor } from 'src/app/models/event-color.model'; 9 | import { NotificationService } from 'src/app/services/notification.service'; 10 | 11 | interface ScheduleOption { 12 | title: string; 13 | icon: string; 14 | link?: string; 15 | click?: () => void; 16 | } 17 | 18 | @Component({ 19 | selector: 'app-schedule', 20 | templateUrl: './schedule.component.html', 21 | styleUrls: ['./schedule.component.scss'] 22 | }) 23 | export class ScheduleComponent implements OnInit { 24 | 25 | private hours: string[]; 26 | private days: string[]; 27 | public linksOptions: ScheduleOption[]; 28 | public clicksOptions: ScheduleOption[]; 29 | private fullName: string; 30 | private schedule: Subject[][]; 31 | private subjectsByDays: Subject[][]; 32 | public isLocationView: boolean; 33 | 34 | constructor( 35 | private dialog: MatDialog, 36 | private authService: AuthService, 37 | private userService: UserService, 38 | private notificationService: NotificationService, 39 | ) { } 40 | 41 | ngOnInit() { 42 | this.schedule = this.userService.scheduleByHours; 43 | this.subjectsByDays = this.userService.subjectsByDays; 44 | this.fullName = this.authService.pomeloData.fullName; 45 | this.days = ['LUNES', 'MARTES', 'MIÉRCOLES', 'JUEVES', 'VIERNES', 'SÁBADO', 'DOMINGO']; 46 | this.isLocationView = false; 47 | this.linksOptions = [ 48 | { 49 | title: 'Seleccionar periodo', 50 | icon: 'calendar_view_day', 51 | link: '/home/period' 52 | }, 53 | { 54 | title: 'Exportar', 55 | icon: 'import_export', 56 | link: '/home/export' 57 | }, 58 | ]; 59 | this.clicksOptions = [ 60 | { 61 | title: 'Descargar', 62 | icon: 'arrow_downward', 63 | click: this.downloadSchedule.bind(this) 64 | }, 65 | ]; 66 | } 67 | 68 | openSubjectDetailsDialog(subject: Subject): void { 69 | if (!subject) { 70 | return; 71 | } 72 | console.log('openSubjectDetailsDialog', subject); 73 | const dialogRef = this.dialog.open(SubjectDetailsDialogComponent, { 74 | width: `${window.innerWidth / 2.2 > 320 ? window.innerWidth / 2.2 : 300 }px`, 75 | data: { editor: true, subject: Object.assign(subject, this.getSubjectStyle(subject)) } 76 | }); 77 | 78 | dialogRef.afterClosed().subscribe(subjectDetailsData => { 79 | if (subjectDetailsData) { 80 | const { color, notificationTime } = subjectDetailsData; 81 | console.log('=> DIALOG CLOSED', color, notificationTime); 82 | this.setSubjectByDaysProperties(subject, color, notificationTime); 83 | this.userService.setSubjectsByDays(this.subjectsByDays); 84 | console.log('subjectsByDays', this.subjectsByDays); 85 | } 86 | }); 87 | } 88 | 89 | getSubjectByDays(subject: Subject) { 90 | return this.subjectsByDays.map((day: Subject[]) => day.find((elem: Subject) => elem.nrc === subject.nrc)).filter(elem => elem); 91 | } 92 | 93 | setSubjectByDaysProperties(subject: Subject, color: EventColor, notificationTime: number) { 94 | this.getSubjectByDays(subject).forEach(subjectByDay => { 95 | if (subjectByDay.color !== color || subjectByDay.notificationTime !== notificationTime) { 96 | subjectByDay.googleSynced = false; 97 | } 98 | subjectByDay.color = color; 99 | subjectByDay.notificationTime = notificationTime; 100 | }); 101 | } 102 | 103 | getSubjectStyle(subject: Subject) { 104 | if (!subject) { 105 | return { color: { foreground: '#1d1d1d' } }; 106 | } 107 | const { color, notificationTime } = this.getSubjectByDays(subject)[0]; 108 | return { color, notificationTime }; 109 | } 110 | 111 | 112 | async downloadSchedule() { 113 | try { 114 | const oldViewPortContent = document.querySelector('meta[name=viewport]').getAttribute('content'); 115 | const viewport = document.querySelector('meta[name=viewport]'); 116 | const windowWidth = 1440; 117 | viewport.setAttribute('content', `width=${windowWidth}`); 118 | const scheduleDiv = document.getElementById('scheduleDiv'); 119 | 120 | const canvas = await html2canvas(scheduleDiv, { height: 615, width: 1340, scrollX: 10, scrollY: 40, windowWidth }) 121 | 122 | viewport.setAttribute('content', oldViewPortContent); 123 | 124 | const link = document.createElement('a'); 125 | link.download = `mihorarioun_${new Date().toLocaleDateString()}`; 126 | link.href = canvas.toDataURL(); 127 | link.click(); 128 | 129 | this.notificationService.add('Horario descargado correctamente'); 130 | } catch(err) { 131 | this.notificationService.add('Ocurrio un error, intenta de nuevo'); 132 | } 133 | } 134 | 135 | toggleView() { 136 | this.isLocationView = !this.isLocationView; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /server/src/services/calendar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * CalendarService 3 | * @author krthr 4 | * @author sjdonado 5 | * @since 1.0.0 6 | */ 7 | 8 | const { google } = require('googleapis'); 9 | const logger = require('../utils/logger'); 10 | const { calendar } = require('../config'); 11 | const { parsePomeloDateToCalendar } = require('../lib/Date'); 12 | 13 | const oauth2Client = new google.auth.OAuth2( 14 | calendar.clientId, 15 | calendar.secretClient, 16 | calendar.callback, 17 | ); 18 | 19 | class CalendarService { 20 | constructor(tokens) { 21 | oauth2Client.setCredentials(tokens); 22 | // if (oauth2Client.isTokenExpiring()) { 23 | // oauth2Client.refreshAccessTokenAsync(); 24 | // } 25 | this.googleCalendar = google.calendar({ 26 | version: 'v3', 27 | auth: oauth2Client, 28 | }); 29 | } 30 | 31 | /** 32 | * Get events at time range 33 | * @param {Date} start 34 | * @param {Date} end 35 | */ 36 | async getEventsAtTimeRange(start, end, multi = false) { 37 | const params = { 38 | calendarId: 'primary', 39 | timeMin: start, 40 | timeMax: end, 41 | }; 42 | 43 | if (!multi) { 44 | Object.assign(params, { 45 | maxResults: 10, 46 | singleEvents: true, 47 | orderBy: 'startTime', 48 | }); 49 | } 50 | 51 | const events = await this.googleCalendar.events.list(params); 52 | 53 | return events.data.items; 54 | } 55 | 56 | /** 57 | * Create a new event 58 | * @param {*} newEvent 59 | */ 60 | async createEvent(newEvent) { 61 | try { 62 | const currentEvent = await this.searchEvent(newEvent); 63 | if (currentEvent) { 64 | return this.googleCalendar.events.update({ 65 | calendarId: 'primary', 66 | eventId: currentEvent.id, 67 | resource: Object.assign(currentEvent, newEvent), 68 | }); 69 | } 70 | return this.googleCalendar.events.insert({ 71 | calendarId: 'primary', 72 | resource: newEvent, 73 | }); 74 | } catch (error) { 75 | return { error }; 76 | } 77 | } 78 | 79 | /** 80 | * Remove event 81 | * @param {*} eventId 82 | */ 83 | deleteEvent(eventId) { 84 | try { 85 | return this.googleCalendar.events.delete({ 86 | calendarId: 'primary', 87 | eventId, 88 | }); 89 | } catch (error) { 90 | return { error }; 91 | } 92 | } 93 | 94 | /** 95 | * Verify if the event already exists 96 | * @param {*} newEvent 97 | */ 98 | async searchEvent(newEvent, deepMatch = false, multi = false) { 99 | const { 100 | start, 101 | end, 102 | summary, 103 | description, 104 | location, 105 | } = newEvent; 106 | 107 | const eventsList = await this.getEventsAtTimeRange(start.dateTime, end.dateTime, multi); 108 | 109 | let event = null; 110 | let events; 111 | if (eventsList.length > 0) { 112 | events = eventsList.filter((obj) => { 113 | if (deepMatch) { 114 | return obj.summary === summary 115 | && obj.description === description 116 | && obj.location === location; 117 | } 118 | return obj.summary === summary; 119 | }); 120 | const [foundEvent] = events; 121 | event = foundEvent; 122 | } 123 | if (multi) return events; 124 | 125 | return event; 126 | } 127 | 128 | getSyncedScheduleEvents(subjects) { 129 | return Promise.all(subjects.map(async (subject) => { 130 | const { 131 | startDate, 132 | endDate, 133 | name, 134 | instructors, 135 | place, 136 | } = subject; 137 | const event = { googleSynced: false }; 138 | try { 139 | const currentEvent = await this.searchEvent({ 140 | start: parsePomeloDateToCalendar(startDate, true), 141 | end: parsePomeloDateToCalendar(endDate, true), 142 | summary: name, 143 | description: instructors, 144 | location: place, 145 | }, true); 146 | if (currentEvent) { 147 | const { colorId, reminders } = currentEvent; 148 | // logger.info('=> getSyncedScheduleEvents: currentEvent', currentEvent); 149 | Object.assign(event, { 150 | googleSynced: event !== null, 151 | color: colorId, 152 | notificationTime: reminders.overrides[0].minutes, 153 | }); 154 | } 155 | } catch (error) { 156 | logger.error(error); 157 | } 158 | return Object.assign(subject, event); 159 | })); 160 | } 161 | 162 | async getAllSyncedEvents(subjects) { 163 | const events = []; 164 | 165 | await Promise.all(subjects.map(async (subject) => { 166 | const { 167 | startDate, 168 | endDate, 169 | name, 170 | instructors, 171 | place, 172 | } = subject; 173 | const params = { 174 | start: parsePomeloDateToCalendar(startDate, true), 175 | end: parsePomeloDateToCalendar(endDate, true), 176 | summary: name, 177 | description: instructors, 178 | location: place, 179 | }; 180 | 181 | const foundEvents = await this.searchEvent(params, true, true); 182 | events.push(...foundEvents.map(({ id }) => ({ subject, eventId: id }))); 183 | })); 184 | 185 | return events; 186 | } 187 | } 188 | 189 | module.exports = CalendarService; 190 | -------------------------------------------------------------------------------- /client/src/app/shared/export-calendar/components/subjects-selector/subjects-selector.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { Subject } from 'src/app/models/subject.model'; 3 | import { FormGroup, FormBuilder, FormArray } from '@angular/forms'; 4 | import { UserService } from 'src/app/components/home/services/user.service'; 5 | import { GoogleCalendarService } from 'src/app/components/home/services/google-calendar.service'; 6 | import { MatDialog } from '@angular/material/dialog'; 7 | import { SubjectDetailsDialogComponent } from 'src/app/shared/dialogs/subject-details-dialog/subject-details-dialog.component'; 8 | import { NotificationService } from 'src/app/services/notification.service'; 9 | 10 | @Component({ 11 | selector: 'app-subjects-selector', 12 | templateUrl: './subjects-selector.component.html', 13 | styleUrls: ['./subjects-selector.component.scss'] 14 | }) 15 | export class SubjectsSelectorComponent implements OnInit, OnDestroy { 16 | 17 | private subjects: Subject[]; 18 | private subjectsByDays: Subject[][]; 19 | private form: FormGroup; 20 | private selectAll = false; 21 | public isLoading: boolean; 22 | 23 | constructor( 24 | private dialog: MatDialog, 25 | private userService: UserService, 26 | private formBuilder: FormBuilder, 27 | private googleCalendarService: GoogleCalendarService, 28 | private notificationService: NotificationService, 29 | ) { } 30 | 31 | ngOnInit() { 32 | this.subjectsByDays = this.userService.subjectsByDays; 33 | this.subjects = this.googleCalendarService.getSubjects(this.subjectsByDays); 34 | // console.log('subjects', this.subjects); 35 | 36 | this.form = this.formBuilder.group({ 37 | selectedSubjects: this.formBuilder.array([]), 38 | }); 39 | 40 | this.subjects.forEach((subject: Subject) => { 41 | (this.form.controls.selectedSubjects as FormArray).push(this.formBuilder.group({ 42 | nrc: this.formBuilder.control(subject.nrc), 43 | color: this.formBuilder.control(subject.color), 44 | notificationTime: this.formBuilder.control(subject.notificationTime), 45 | checked: this.formBuilder.control(false), 46 | })); 47 | }); 48 | } 49 | 50 | ngOnDestroy() { 51 | this.notificationService.stopAll(); 52 | } 53 | 54 | sendSubjects() { 55 | const selectedSubjects = this.form.value.selectedSubjects.filter(subject => subject.checked); 56 | if (!selectedSubjects.length) { 57 | return; 58 | } 59 | this.isLoading = true; 60 | const subjects = this.subjectsByDays.map(day => day.map((subject) => { 61 | const selectedSubject = selectedSubjects.find(elem => elem.nrc === subject.nrc); 62 | if (selectedSubject) { 63 | const { color, notificationTime } = selectedSubject; 64 | return Object.assign(subject, { colorId: color.id, notificationTime }); 65 | } 66 | }).filter(elem => elem)); 67 | console.log('subjectsByDays', this.subjectsByDays); 68 | console.log('selectedSubjects', selectedSubjects); 69 | console.log('finalSubjects', subjects); 70 | 71 | this.googleCalendarService.importSubjects(subjects) 72 | .subscribe( 73 | (res: any) => { 74 | console.log(res); 75 | const { data } = res; 76 | let sucessImportsCount = 0; 77 | const importedSubjects = []; 78 | data.forEach(subjectResponse => { 79 | console.log('subjectResponse', subjectResponse); 80 | const subject = this.subjects.find(elem => elem.nrc === subjectResponse.data.subject.nrc); 81 | const imported = importedSubjects.indexOf(subject.nrc) !== -1; 82 | if (!imported) { 83 | importedSubjects.push(subject.nrc); 84 | } 85 | if (subjectResponse.status && subjectResponse.status === 200) { 86 | subject.googleSynced = true; 87 | if (!imported) { 88 | sucessImportsCount += 1; 89 | } 90 | } else { 91 | subject.googleSynced = false; 92 | const message = `Error importando ${subjectResponse.data.subject.shortName}.`; 93 | if (!this.notificationService.isInQueue(message)) { 94 | this.notificationService.add(message); 95 | } 96 | } 97 | }); 98 | this.notificationService.add(`Materias importadas: ${sucessImportsCount}/${importedSubjects.length}`); 99 | this.updateSubjectByDays(); 100 | this.form.reset(); 101 | this.selectAll = false; 102 | this.isLoading = false; 103 | }, 104 | (err) => { 105 | this.isLoading = false; 106 | this.notificationService.add('Error importando las materias seleccionadas, intenta de nuevo.'); 107 | console.log('Error', err); 108 | } 109 | ); 110 | } 111 | 112 | removeSubjects() { 113 | const selectedSubjects = this.form.value.selectedSubjects.filter(subject => subject.checked); 114 | if (!selectedSubjects.length) { 115 | return; 116 | } 117 | this.isLoading = true; 118 | const subjects = []; 119 | this.subjectsByDays.forEach(day => day.forEach((subject) => { 120 | if (selectedSubjects.some(elem => elem.nrc === subject.nrc)) { 121 | subjects.push(subject); 122 | } 123 | })); 124 | // console.log('subjectsByDays', this.subjectsByDays); 125 | // console.log('selectedSubjects', selectedSubjects); 126 | // console.log('finalSubjects', subjects); 127 | this.googleCalendarService.removeSubjects(subjects) 128 | .subscribe( 129 | (res: any) => { 130 | console.log(res); 131 | this.subjects = res.data; 132 | this.notificationService.add(`Materias removidas: ${res.data.length}/${subjects.length}`); 133 | this.form.reset(); 134 | this.selectAll = false; 135 | this.isLoading = false; 136 | }, 137 | (err) => { 138 | this.isLoading = false; 139 | this.notificationService.add('Error removiendo las materias seleccionadas, intenta de nuevo.'); 140 | console.log('Error', err); 141 | } 142 | ); 143 | } 144 | 145 | openSubjectDetailsDialog(subject: Subject) { 146 | this.dialog.open(SubjectDetailsDialogComponent, { 147 | width: `${window.innerWidth / 2.2 > 320 ? window.innerWidth / 2.2 : 300 }px`, 148 | data: { editor: false, subject } 149 | }); 150 | } 151 | 152 | onSelectAllChange() { 153 | (this.form.controls.selectedSubjects as FormArray).controls 154 | .forEach(control => control.setValue(Object.assign(control.value, { checked: this.selectAll }))); 155 | } 156 | 157 | updateSubjectByDays() { 158 | const updatedSubjectsByDays = this.userService.subjectsByDays.map((day: Subject[]) => day.map((subject: Subject) => { 159 | const selectedSubject = this.subjects.find(elem => elem.nrc === subject.nrc); 160 | return Object.assign(subject, selectedSubject); 161 | })); 162 | this.userService.setSubjectsByDays(updatedSubjectsByDays); 163 | } 164 | 165 | get getFormGroup() { 166 | return this.form; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /server/tests/models/googleCalendar.test.js: -------------------------------------------------------------------------------- 1 | const { google } = require('googleapis'); 2 | const model = require('../../src/api/v1/googleCalendar/model'); 3 | 4 | jest.mock('googleapis'); 5 | 6 | const CALENDAR_EVENTS_RESPONSE = [ 7 | { 8 | eventId: 1, 9 | location: 'SPACE VIRT', 10 | summary: 'MATEMATICAS DISCRETAS AVANZADA', 11 | description: 'Gutierrez Garcia, Ismael', 12 | start: { 13 | timeZone: 'America/Bogota', 14 | dateTime: '2019-01-25T13:30:00-05:00', 15 | }, 16 | end: { 17 | timeZone: 'America/Bogota', 18 | dateTime: '2019-01-25T16:27:00-05:00', 19 | }, 20 | reminders: { 21 | useDefault: false, 22 | overrides: [ 23 | { 24 | method: 'popup', 25 | minutes: 15, 26 | }, 27 | ], 28 | }, 29 | recurrence: [ 30 | 'RRULE:FREQ=WEEKLY;UNTIL=20190316', 31 | ], 32 | colorId: 2, 33 | }, 34 | { 35 | eventId: 2, 36 | location: 'SPACE VIRT', 37 | summary: 'MATEMATICAS DISCRETAS AVANZADA', 38 | description: 'Gutierrez Garcia, Ismael', 39 | start: { 40 | timeZone: 'America/Bogota', 41 | dateTime: '2019-01-25T13:30:00-05:00', 42 | }, 43 | end: { 44 | timeZone: 'America/Bogota', 45 | dateTime: '2019-01-25T16:27:00-05:00', 46 | }, 47 | reminders: { 48 | useDefault: false, 49 | overrides: [ 50 | { 51 | method: 'popup', 52 | minutes: 15, 53 | }, 54 | ], 55 | }, 56 | recurrence: [ 57 | 'RRULE:FREQ=WEEKLY;UNTIL=20190316', 58 | ], 59 | colorId: 2, 60 | }, 61 | { 62 | eventId: 3, 63 | location: 'SPACE VIRT', 64 | summary: 'MATEMATICAS DISCRETAS AVANZADA', 65 | description: 'Gutierrez Garcia, Ismael', 66 | start: { 67 | timeZone: 'America/Bogota', 68 | dateTime: '2019-01-25T13:30:00-05:00', 69 | }, 70 | end: { 71 | timeZone: 'America/Bogota', 72 | dateTime: '2019-01-25T16:27:00-05:00', 73 | }, 74 | reminders: { 75 | useDefault: false, 76 | overrides: [ 77 | { 78 | method: 'popup', 79 | minutes: 15, 80 | }, 81 | ], 82 | }, 83 | recurrence: [ 84 | 'RRULE:FREQ=WEEKLY;UNTIL=20190316', 85 | ], 86 | colorId: 2, 87 | }, 88 | { 89 | eventId: 4, 90 | location: 'SPACE VIRT', 91 | summary: 'INTELIGENCIA ARTIFICIAL', 92 | description: 'Zurek Varela, Eduardo E.', 93 | start: { 94 | timeZone: 'America/Bogota', 95 | dateTime: '2019-01-26T16:30:00-05:00', 96 | }, 97 | end: { 98 | timeZone: 'America/Bogota', 99 | dateTime: '2019-01-26T19:27:00-05:00', 100 | }, 101 | reminders: { 102 | useDefault: false, 103 | overrides: [ 104 | { 105 | method: 'popup', 106 | minutes: 15, 107 | }, 108 | ], 109 | }, 110 | recurrence: [ 111 | 'RRULE:FREQ=WEEKLY;UNTIL=20190324', 112 | ], 113 | colorId: 3, 114 | }, 115 | { 116 | eventId: 5, 117 | location: 'SPACE VIRT', 118 | summary: 'INTELIGENCIA ARTIFICIAL', 119 | description: 'Zurek Varela, Eduardo E.', 120 | start: { 121 | timeZone: 'America/Bogota', 122 | dateTime: '2019-01-26T16:30:00-05:00', 123 | }, 124 | end: { 125 | timeZone: 'America/Bogota', 126 | dateTime: '2019-01-26T19:27:00-05:00', 127 | }, 128 | reminders: { 129 | useDefault: false, 130 | overrides: [ 131 | { 132 | method: 'popup', 133 | minutes: 15, 134 | }, 135 | ], 136 | }, 137 | recurrence: [ 138 | 'RRULE:FREQ=WEEKLY;UNTIL=20190324', 139 | ], 140 | colorId: 3, 141 | }, 142 | { 143 | eventId: 6, 144 | location: 'SPACE VIRT', 145 | summary: 'INGENIERIA DE REDES Y CONEC I', 146 | description: 'Jabba Molinares, Daladier', 147 | start: { 148 | timeZone: 'America/Bogota', 149 | dateTime: '2019-01-28T16:30:00-05:00', 150 | }, 151 | end: { 152 | timeZone: 'America/Bogota', 153 | dateTime: '2019-01-28T19:27:00-05:00', 154 | }, 155 | reminders: { 156 | useDefault: false, 157 | overrides: [ 158 | { 159 | method: 'popup', 160 | minutes: 15, 161 | }, 162 | ], 163 | }, 164 | recurrence: [ 165 | 'RRULE:FREQ=WEEKLY;UNTIL=20190326', 166 | ], 167 | colorId: 4, 168 | }, 169 | { 170 | eventId: 7, 171 | location: 'SPACE VIRT', 172 | summary: 'INGENIERIA DE REDES Y CONEC I', 173 | description: 'Jabba Molinares, Daladier', 174 | start: { 175 | timeZone: 'America/Bogota', 176 | dateTime: '2019-01-28T16:30:00-05:00', 177 | }, 178 | end: { 179 | timeZone: 'America/Bogota', 180 | dateTime: '2019-01-28T19:27:00-05:00', 181 | }, 182 | reminders: { 183 | useDefault: false, 184 | overrides: [ 185 | { 186 | method: 'popup', 187 | minutes: 15, 188 | }, 189 | ], 190 | }, 191 | recurrence: [ 192 | 'RRULE:FREQ=WEEKLY;UNTIL=20190326', 193 | ], 194 | colorId: 4, 195 | }, 196 | ]; 197 | 198 | const CALENDAR_INSERT_EVENT = { 199 | eventId: 8, 200 | location: 'SPACE VIRT', 201 | summary: 'INVESTIGACION 2', 202 | description: 'Niño Ruiz, Elias D.', 203 | start: { 204 | timeZone: 'America/Bogota', 205 | dateTime: '2019-01-31T06:30:00-05:00', 206 | }, 207 | end: { 208 | timeZone: 'America/Bogota', 209 | dateTime: '2019-01-31T07:29:00-05:00', 210 | }, 211 | reminders: { 212 | useDefault: false, 213 | overrides: [ 214 | { 215 | method: 'popup', 216 | minutes: 15, 217 | }, 218 | ], 219 | }, 220 | recurrence: [ 221 | 'RRULE:FREQ=WEEKLY;UNTIL=20190520', 222 | ], 223 | colorId: 1, 224 | }; 225 | 226 | const CALENDAR_INVALID_SUBJECT = { 227 | eventId: null, 228 | }; 229 | 230 | google.calendar.mockImplementation(() => ({ 231 | events: { 232 | update: () => ({ 233 | data: null, 234 | }), 235 | insert: ({ resource }) => { 236 | if (!resource.eventId) { 237 | throw new Error('EventId is null'); 238 | } 239 | CALENDAR_EVENTS_RESPONSE.push(CALENDAR_INSERT_EVENT); 240 | return CALENDAR_INSERT_EVENT; 241 | }, 242 | list: () => ({ 243 | data: { items: CALENDAR_EVENTS_RESPONSE }, 244 | }), 245 | delete: ({ eventId }) => { 246 | if (!eventId) { 247 | throw new Error('EventId is null'); 248 | } 249 | CALENDAR_EVENTS_RESPONSE.map((subject) => subject.eventId !== eventId); 250 | return CALENDAR_EVENTS_RESPONSE.findOne((elem) => elem.eventId === eventId); 251 | }, 252 | }, 253 | })); 254 | 255 | const SUBJECTS = [ 256 | { 257 | nrc: '9621', 258 | name: 'MATEMATICAS DISCRETAS AVANZADA', 259 | shortName: 'MATEMATICAS DISCRETAS AVANZADA', 260 | instructors: 'Gutierrez Garcia, Ismael', 261 | type: 'IST 42007', 262 | place: 'SPACE VIRT', 263 | startDate: 'Jan 25, 2019', 264 | endDate: 'Mar 16, 2019', 265 | startTime: '01:30 PM', 266 | endTime: '04:27 PM', 267 | color: { 268 | id: 2, 269 | background: '#7ae7bf', 270 | foreground: '#1d1d1d', 271 | }, 272 | notificationTime: 15, 273 | googleSynced: false, 274 | colorId: 2, 275 | }, 276 | { 277 | nrc: '9621', 278 | name: 'MATEMATICAS DISCRETAS AVANZADA', 279 | shortName: 'MATEMATICAS DISCRETAS AVANZADA', 280 | instructors: 'Gutierrez Garcia, Ismael', 281 | type: 'IST 42007', 282 | place: 'SPACE VIRT', 283 | startDate: 'Apr 05, 2019', 284 | endDate: 'May 11, 2019', 285 | startTime: '01:30 PM', 286 | endTime: '04:27 PM', 287 | color: { 288 | id: 2, 289 | background: '#7ae7bf', 290 | foreground: '#1d1d1d', 291 | }, 292 | notificationTime: 15, 293 | googleSynced: false, 294 | colorId: 2, 295 | }, 296 | { 297 | nrc: '9621', 298 | name: 'MATEMATICAS DISCRETAS AVANZADA', 299 | shortName: 'MATEMATICAS DISCRETAS AVANZADA', 300 | instructors: 'Gutierrez Garcia, Ismael', 301 | type: 'IST 42007', 302 | place: 'SPACE VIRT', 303 | startDate: 'May 18, 2019', 304 | endDate: 'Jun 01, 2019', 305 | startTime: '01:30 PM', 306 | endTime: '04:27 PM', 307 | color: { 308 | id: 2, 309 | background: '#7ae7bf', 310 | foreground: '#1d1d1d', 311 | }, 312 | notificationTime: 15, 313 | googleSynced: false, 314 | colorId: 2, 315 | }, 316 | { 317 | nrc: '9626', 318 | name: 'INTELIGENCIA ARTIFICIAL', 319 | shortName: 'INTELIGENCIA ARTIFICIAL', 320 | instructors: 'Zurek Varela, Eduardo E.', 321 | type: 'IST 62008', 322 | place: 'SPACE VIRT', 323 | startDate: 'Jan 25, 2019', 324 | endDate: 'Mar 24, 2019', 325 | startTime: '04:30 PM', 326 | endTime: '07:27 PM', 327 | color: { 328 | id: 3, 329 | background: '#dbadff', 330 | foreground: '#1d1d1d', 331 | }, 332 | notificationTime: 15, 333 | googleSynced: false, 334 | colorId: 3, 335 | }, 336 | { 337 | nrc: '9626', 338 | name: 'INTELIGENCIA ARTIFICIAL', 339 | shortName: 'INTELIGENCIA ARTIFICIAL', 340 | instructors: 'Zurek Varela, Eduardo E.', 341 | type: 'IST 62008', 342 | place: 'SPACE VIRT', 343 | startDate: 'Apr 06, 2019', 344 | endDate: 'May 18, 2019', 345 | startTime: '04:30 PM', 346 | endTime: '07:27 PM', 347 | color: { 348 | id: 3, 349 | background: '#dbadff', 350 | foreground: '#1d1d1d', 351 | }, 352 | notificationTime: 15, 353 | googleSynced: false, 354 | colorId: 3, 355 | }, 356 | { 357 | nrc: '9639', 358 | name: 'INGENIERIA DE REDES Y CONEC I', 359 | shortName: 'INGENIERIA DE REDES Y CONEC I', 360 | instructors: 'Jabba Molinares, Daladier', 361 | type: 'IST 62018', 362 | place: 'SPACE VIRT', 363 | startDate: 'Jan 25, 2019', 364 | endDate: 'Mar 26, 2019', 365 | startTime: '04:30 PM', 366 | endTime: '07:27 PM', 367 | color: { 368 | id: 4, 369 | background: '#ff887c', 370 | foreground: '#1d1d1d', 371 | }, 372 | notificationTime: 15, 373 | googleSynced: false, 374 | colorId: 4, 375 | }, 376 | { 377 | nrc: '9639', 378 | name: 'INGENIERIA DE REDES Y CONEC I', 379 | shortName: 'INGENIERIA DE REDES Y CONEC I', 380 | instructors: 'Jabba Molinares, Daladier', 381 | type: 'IST 62018', 382 | place: 'SPACE VIRT', 383 | startDate: 'Apr 07, 2019', 384 | endDate: 'May 20, 2019', 385 | startTime: '04:30 PM', 386 | endTime: '07:27 PM', 387 | color: { 388 | id: 4, 389 | background: '#ff887c', 390 | foreground: '#1d1d1d', 391 | }, 392 | notificationTime: 15, 393 | googleSynced: false, 394 | colorId: 4, 395 | }, 396 | { 397 | nrc: '9548', 398 | name: 'INVESTIGACION 2', 399 | shortName: 'INVESTIGACION 2', 400 | instructors: 'Niño Ruiz, Elias D.', 401 | type: 'INV 42028', 402 | place: 'SPACE VIRT', 403 | startDate: 'Jan 25, 2019', 404 | endDate: 'May 20, 2019', 405 | startTime: '06:30 AM', 406 | endTime: '07:29 AM', 407 | color: { 408 | id: 1, 409 | background: '#a4bdfc', 410 | foreground: '#1d1d1d', 411 | }, 412 | notificationTime: 15, 413 | googleSynced: false, 414 | colorId: 1, 415 | }, 416 | ]; 417 | 418 | const TOKENS = { 419 | access_token: 'ACCESS_TOKEN', 420 | refresh_token: 'REFRESH_TOKEN', 421 | }; 422 | 423 | describe('Test GoogleCalendar model', () => { 424 | it('Should import schedule', async () => { 425 | const subjectsMatrix = [ 426 | SUBJECTS.slice(0, 3), 427 | SUBJECTS.slice(3, 5), 428 | [], 429 | SUBJECTS.slice(5, 7), 430 | [], 431 | [], 432 | SUBJECTS.slice(-1), 433 | ]; 434 | 435 | const events = await model.importSchedule(TOKENS, subjectsMatrix); 436 | 437 | expect(events[0].data.subject).toEqual(subjectsMatrix[0][0]); 438 | expect(events[1].data.subject).toEqual(subjectsMatrix[0][1]); 439 | expect(events[2].data.subject).toEqual(subjectsMatrix[0][2]); 440 | 441 | expect(events[3].data.subject).toEqual(subjectsMatrix[1][0]); 442 | expect(events[4].data.subject).toEqual(subjectsMatrix[1][1]); 443 | 444 | expect(events[5].data.subject).toEqual(subjectsMatrix[3][0]); 445 | expect(events[6].data.subject).toEqual(subjectsMatrix[3][1]); 446 | 447 | expect(events[7].data.subject).toEqual(subjectsMatrix[6][0]); 448 | }); 449 | 450 | it('Should import schedule with an eventId error', async () => { 451 | const subjectsMatrix = [ 452 | SUBJECTS.slice(0, 3), 453 | SUBJECTS.slice(3, 5), 454 | [], 455 | SUBJECTS.slice(5, 7), 456 | [], 457 | [CALENDAR_INVALID_SUBJECT], 458 | SUBJECTS.slice(-1), 459 | ]; 460 | 461 | const events = await model.importSchedule(TOKENS, subjectsMatrix); 462 | expect(events[7].error.message).toEqual('EventId is null'); 463 | }); 464 | 465 | it('Should get synced subjects', async () => { 466 | const events = await model.getSyncedSubjects(TOKENS, SUBJECTS); 467 | 468 | expect(events[0].nrc).toEqual(SUBJECTS[0].nrc); 469 | expect(events[1].nrc).toEqual(SUBJECTS[1].nrc); 470 | expect(events[2].nrc).toEqual(SUBJECTS[2].nrc); 471 | expect(events[3].nrc).toEqual(SUBJECTS[3].nrc); 472 | expect(events[4].nrc).toEqual(SUBJECTS[4].nrc); 473 | expect(events[5].nrc).toEqual(SUBJECTS[5].nrc); 474 | expect(events[6].nrc).toEqual(SUBJECTS[6].nrc); 475 | expect(events[7].nrc).toEqual(SUBJECTS[7].nrc); 476 | }); 477 | 478 | it('Should remove subject', async () => { 479 | const removeSubjects = SUBJECTS.slice(0, 2); 480 | const subjects = await model.removeSubjects(TOKENS, removeSubjects); 481 | 482 | expect(subjects[0].googleSynced).toBe(false); 483 | expect(subjects[1].googleSynced).toBe(false); 484 | }); 485 | }); 486 | --------------------------------------------------------------------------------