12 | */
13 | export const expandCollapse = trigger('expandCollapse', [
14 | state('*', style({
15 | 'overflow-y': 'hidden',
16 | 'height': '*'
17 | })),
18 | state('void', style({
19 | 'height': '0',
20 | 'overflow-y': 'hidden'
21 | })),
22 | transition('* => void', animate('250ms ease-out')),
23 | transition('void => *', animate('250ms ease-in'))
24 | ]);
25 |
--------------------------------------------------------------------------------
/src/assets/images/eye.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/pages/event/event-detail/event-detail.component.html:
--------------------------------------------------------------------------------
1 |
2 |
Event Details
3 |
4 |
5 |
6 | -
7 | When:{{ utils.eventDatesTimes(event.startDatetime, event.endDatetime) }}
8 |
9 | -
10 | Where:{{ event.location }} (get directions)
11 |
12 |
13 |
14 |
17 |
18 |
21 |
--------------------------------------------------------------------------------
/src/app/pages/event/event.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { CommonModule } from '@angular/common';
3 | import { CoreModule } from './../../core/core.module';
4 | import { RouterModule } from '@angular/router';
5 | import { EVENT_ROUTES } from './event.routes';
6 | import { EventComponent } from './event.component';
7 | import { EventDetailComponent } from './event-detail/event-detail.component';
8 | import { RsvpComponent } from './rsvp/rsvp.component';
9 | import { RsvpFormComponent } from './rsvp/rsvp-form/rsvp-form.component';
10 |
11 | @NgModule({
12 | imports: [
13 | CommonModule,
14 | CoreModule,
15 | RouterModule.forChild(EVENT_ROUTES)
16 | ],
17 | declarations: [
18 | EventComponent,
19 | EventDetailComponent,
20 | RsvpComponent,
21 | RsvpFormComponent
22 | ]
23 | })
24 | export class EventModule { }
25 |
--------------------------------------------------------------------------------
/src/app/auth/auth.guard.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
3 | import { Observable } from 'rxjs';
4 | import { AuthService } from './auth.service';
5 |
6 | @Injectable()
7 | export class AuthGuard implements CanActivate {
8 |
9 | constructor(private auth: AuthService) { }
10 |
11 | canActivate(
12 | next: ActivatedRouteSnapshot,
13 | state: RouterStateSnapshot
14 | ): Observable
| Promise | boolean {
15 | if (!this.auth.loggedIn) {
16 | localStorage.setItem('authRedirect', state.url);
17 | }
18 | if (!this.auth.tokenValid && !this.auth.loggedIn) {
19 | this.auth.login();
20 | return false;
21 | }
22 | if (this.auth.tokenValid && this.auth.loggedIn) {
23 | return true;
24 | }
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/protractor.conf.js:
--------------------------------------------------------------------------------
1 | // Protractor configuration file, see link for more information
2 | // https://github.com/angular/protractor/blob/master/lib/config.ts
3 |
4 | const { SpecReporter } = require('jasmine-spec-reporter');
5 |
6 | exports.config = {
7 | allScriptsTimeout: 11000,
8 | specs: [
9 | './e2e/**/*.e2e-spec.ts'
10 | ],
11 | capabilities: {
12 | 'browserName': 'chrome'
13 | },
14 | directConnect: true,
15 | baseUrl: 'http://localhost:4200/',
16 | framework: 'jasmine',
17 | jasmineNodeOpts: {
18 | showColors: true,
19 | defaultTimeoutInterval: 30000,
20 | print: function() {}
21 | },
22 | beforeLaunch: function() {
23 | require('ts-node').register({
24 | project: 'e2e/tsconfig.e2e.json'
25 | });
26 | },
27 | onPrepare() {
28 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/src/app/app.component.scss:
--------------------------------------------------------------------------------
1 | /*--------------------
2 | APP COMPONENT
3 | --------------------*/
4 |
5 | @import '../assets/scss/partials/layout.vars';
6 | @import '../assets/scss/partials/responsive.partial';
7 |
8 | .layout-overflow {
9 | overflow: hidden; /* necessary to handle offcanvas scrollbar behavior */
10 | }
11 | .layout-canvas {
12 | background: #fff;
13 | backface-visibility: hidden;
14 | -webkit-backface-visibility: hidden; /* Safari: http://caniuse.com/#search=css3%203d */
15 | position: relative;
16 | left: 0;
17 | transition: transform 250ms ease;
18 | transform: translate3d(0,0,0);
19 | width: 100%;
20 |
21 | &.nav-open {
22 | transform: translate3d(270px,0,0);
23 | }
24 | }
25 | .layout-view {
26 | padding: $padding-screen-small;
27 |
28 | @include mq($large) {
29 | margin: 0 auto;
30 | max-width: 960px;
31 | padding: $padding-screen-large;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit } from '@angular/core';
2 | import { fromEvent } from 'rxjs';
3 | import { debounceTime } from 'rxjs/operators';
4 |
5 | @Component({
6 | selector: 'app-root',
7 | templateUrl: './app.component.html',
8 | styleUrls: ['./app.component.scss']
9 | })
10 | export class AppComponent implements OnInit {
11 | navOpen: boolean;
12 | minHeight: string;
13 | private _initWinHeight = 0;
14 |
15 | constructor() { }
16 |
17 | ngOnInit() {
18 | fromEvent(window, 'resize')
19 | .pipe(
20 | debounceTime(200)
21 | )
22 | .subscribe((event) => this._resizeFn(event));
23 |
24 | this._initWinHeight = window.innerHeight;
25 | this._resizeFn(null);
26 | }
27 |
28 | navToggledHandler(e: boolean) {
29 | this.navOpen = e;
30 | }
31 |
32 | private _resizeFn(e) {
33 | const winHeight: number = e ? e.target.innerHeight : this._initWinHeight;
34 | this.minHeight = `${winHeight}px`;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/pages/admin/update-event/delete-event/delete-event.component.html:
--------------------------------------------------------------------------------
1 |
2 | You are deleting the "" event.
3 |
4 |
5 |
6 | Deleting this event will also remove all associated RSVPs. Please proceed with caution!
7 |
8 |
9 |
10 |
11 |
16 |
17 |
18 |
19 |
20 |
24 |
25 |
26 |
27 |
28 |
29 | Oops! There was an error deleting this event. Please try again.
30 |
31 |
--------------------------------------------------------------------------------
/src/app/pages/admin/admin.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { CommonModule } from '@angular/common';
3 | import { CoreModule } from './../../core/core.module';
4 | import { RouterModule } from '@angular/router';
5 | import { ADMIN_ROUTES } from './admin.routes';
6 | import { AdminComponent } from './admin.component';
7 | import { CreateEventComponent } from './create-event/create-event.component';
8 | import { UpdateEventComponent } from './update-event/update-event.component';
9 | import { EventFormComponent } from './event-form/event-form.component';
10 | import { DeleteEventComponent } from './update-event/delete-event/delete-event.component';
11 |
12 | @NgModule({
13 | imports: [
14 | CommonModule,
15 | CoreModule,
16 | RouterModule.forChild(ADMIN_ROUTES)
17 | ],
18 | declarations: [
19 | AdminComponent,
20 | CreateEventComponent,
21 | UpdateEventComponent,
22 | EventFormComponent,
23 | DeleteEventComponent
24 | ]
25 | })
26 | export class AdminModule { }
27 |
--------------------------------------------------------------------------------
/src/app/header/header.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, Output, EventEmitter } from '@angular/core';
2 | import { Router, NavigationStart } from '@angular/router';
3 | import { AuthService } from './../auth/auth.service';
4 | import { filter } from 'rxjs/operators';
5 |
6 | @Component({
7 | selector: 'app-header',
8 | templateUrl: './header.component.html',
9 | styleUrls: ['./header.component.scss']
10 | })
11 | export class HeaderComponent implements OnInit {
12 | @Output() navToggled = new EventEmitter();
13 | navOpen = false;
14 |
15 | constructor(
16 | private router: Router,
17 | public auth: AuthService
18 | ) { }
19 |
20 | ngOnInit() {
21 | // If nav is open after routing, close it
22 | this.router.events
23 | .pipe(
24 | filter(event => event instanceof NavigationStart && this.navOpen)
25 | )
26 | .subscribe(event => this.toggleNav());
27 | }
28 |
29 | toggleNav() {
30 | this.navOpen = !this.navOpen;
31 | this.navToggled.emit(this.navOpen);
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/app.module.ts:
--------------------------------------------------------------------------------
1 | import { BrowserModule } from '@angular/platform-browser';
2 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
3 | import { NgModule } from '@angular/core';
4 |
5 | import { AuthModule } from './auth/auth.module';
6 | import { CoreModule } from './core/core.module';
7 | import { AppRoutingModule } from './app-routing.module';
8 |
9 | import { AppComponent } from './app.component';
10 | import { HomeComponent } from './pages/home/home.component';
11 | import { CallbackComponent } from './pages/callback/callback.component';
12 | import { MyRsvpsComponent } from './pages/my-rsvps/my-rsvps.component';
13 |
14 | @NgModule({
15 | declarations: [
16 | AppComponent,
17 | HomeComponent,
18 | CallbackComponent,
19 | MyRsvpsComponent
20 | ],
21 | imports: [
22 | BrowserModule,
23 | BrowserAnimationsModule,
24 | AppRoutingModule,
25 | AuthModule.forRoot(),
26 | CoreModule.forRoot()
27 | ],
28 | providers: [],
29 | bootstrap: [AppComponent]
30 | })
31 | export class AppModule { }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Auth0
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/app/core/forms/date-range.validator.ts:
--------------------------------------------------------------------------------
1 | import { AbstractControl } from '@angular/forms';
2 | import { stringsToDate } from './formUtils.factory';
3 |
4 | export function dateRangeValidator(c: AbstractControl): {[key: string]: any} {
5 | // Get controls in group
6 | const startDateC = c.get('startDate');
7 | const startTimeC = c.get('startTime');
8 | const endDateC = c.get('endDate');
9 | const endTimeC = c.get('endTime');
10 | // Object to return if date is invalid
11 | const invalidObj = { 'dateRange': true };
12 |
13 | // If start and end dates are valid, can check range (with prefilled times)
14 | // Final check happens when all dates/times are valid
15 | if (startDateC.valid && endDateC.valid) {
16 | const checkStartTime = startTimeC.invalid ? '12:00 AM' : startTimeC.value;
17 | const checkEndTime = endTimeC.invalid ? '11:59 PM' : endTimeC.value;
18 | const startDatetime = stringsToDate(startDateC.value, checkStartTime);
19 | const endDatetime = stringsToDate(endDateC.value, checkEndTime);
20 |
21 | if (endDatetime >= startDatetime) {
22 | return null;
23 | } else {
24 | return invalidObj;
25 | }
26 | }
27 | return null;
28 | }
29 |
--------------------------------------------------------------------------------
/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/long-stack-trace-zone';
4 | import 'zone.js/dist/proxy.js';
5 | import 'zone.js/dist/sync-test';
6 | import 'zone.js/dist/jasmine-patch';
7 | import 'zone.js/dist/async-test';
8 | import 'zone.js/dist/fake-async-test';
9 | import { getTestBed } from '@angular/core/testing';
10 | import {
11 | BrowserDynamicTestingModule,
12 | platformBrowserDynamicTesting
13 | } from '@angular/platform-browser-dynamic/testing';
14 |
15 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
16 | declare var __karma__: any;
17 | declare var require: any;
18 |
19 | // Prevent Karma from running prematurely.
20 | __karma__.loaded = function () {};
21 |
22 | // First, initialize the Angular testing environment.
23 | getTestBed().initTestEnvironment(
24 | BrowserDynamicTestingModule,
25 | platformBrowserDynamicTesting()
26 | );
27 | // Then we find all the tests.
28 | const context = require.context('./', true, /\.spec\.ts$/);
29 | // And load the modules.
30 | context.keys().map(context);
31 | // Finally, start Karma to run the tests.
32 | __karma__.start();
33 |
--------------------------------------------------------------------------------
/src/app/core/forms/formUtils.factory.ts:
--------------------------------------------------------------------------------
1 | // 0-9
2 | // https://regex101.com/r/dU0eY6/1
3 | const GUESTS_REGEX = new RegExp(/^[0-9]$/);
4 | // mm/dd/yyyy, m/d/yyyy
5 | // https://regex101.com/r/7iSsmm/2
6 | const DATE_REGEX = new RegExp(/^(\d{2}|\d)\/(\d{2}|\d)\/\d{4}$/);
7 | // h:mm am/pm, hh:mm AM/PM
8 | // https://regex101.com/r/j2Cfqd/1/
9 | const TIME_REGEX = new RegExp(/^((1[0-2]|0?[1-9]):([0-5][0-9]) ([AaPp][Mm]))$/);
10 |
11 | // Converts date + time strings to a Date object.
12 | // Date and time parameters should have already
13 | // been validated with DATE_REGEX and TIME_REGEX.
14 | function stringsToDate(dateStr: string, timeStr: string) {
15 | if (!DATE_REGEX.test(dateStr) || !TIME_REGEX.test(timeStr)) {
16 | console.error('Cannot convert date/time to Date object.');
17 | return;
18 | }
19 | const date = new Date(dateStr);
20 | const timeArr = timeStr.split(/[\s:]+/); // https://regex101.com/r/H4dMvA/1
21 | let hour = parseInt(timeArr[0], 10);
22 | const min = parseInt(timeArr[1], 10);
23 | const pm = timeArr[2].toLowerCase() === 'pm';
24 |
25 | if (!pm && hour === 12) {
26 | hour = 0;
27 | }
28 | if (pm && hour < 12) {
29 | hour += 12;
30 | }
31 | date.setHours(hour);
32 | date.setMinutes(min);
33 | return date;
34 | }
35 |
36 | export { GUESTS_REGEX, DATE_REGEX, TIME_REGEX, stringsToDate };
37 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration file, see link for more information
2 | // https://karma-runner.github.io/0.13/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 | files: [
19 |
20 | ],
21 | preprocessors: {
22 |
23 | },
24 | mime: {
25 | 'text/x-typescript': ['ts','tsx']
26 | },
27 | coverageIstanbulReporter: {
28 | dir: require('path').join(__dirname, 'coverage'), reports: [ 'html', 'lcovonly' ],
29 | fixWebpackSourcePaths: true
30 | },
31 | angularCli: {
32 | environment: 'dev'
33 | },
34 | reporters: config.angularCli && config.angularCli.codeCoverage
35 | ? ['progress', 'coverage-istanbul']
36 | : ['progress', 'kjhtml'],
37 | port: 9876,
38 | colors: true,
39 | logLevel: config.LOG_INFO,
40 | autoWatch: true,
41 | browsers: ['Chrome'],
42 | singleRun: false
43 | });
44 | };
45 |
--------------------------------------------------------------------------------
/src/app/app-routing.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { Routes, RouterModule } from '@angular/router';
3 | // Route guards
4 | import { AuthGuard } from './auth/auth.guard';
5 | import { AdminGuard } from './auth/admin.guard';
6 | // Page components
7 | import { HomeComponent } from './pages/home/home.component';
8 | import { CallbackComponent } from './pages/callback/callback.component';
9 | import { MyRsvpsComponent } from './pages/my-rsvps/my-rsvps.component';
10 |
11 | const routes: Routes = [
12 | {
13 | path: '',
14 | component: HomeComponent
15 | },
16 | {
17 | path: 'callback',
18 | component: CallbackComponent
19 | },
20 | {
21 | path: 'event/:id',
22 | loadChildren: './pages/event/event.module#EventModule',
23 | canActivate: [
24 | AuthGuard
25 | ]
26 | },
27 | {
28 | path: 'my-rsvps',
29 | component: MyRsvpsComponent,
30 | canActivate: [
31 | AuthGuard
32 | ]
33 | },
34 | {
35 | path: 'admin',
36 | loadChildren: './pages/admin/admin.module#AdminModule',
37 | canActivate: [
38 | AuthGuard,
39 | AdminGuard
40 | ]
41 | },
42 | {
43 | path: '**',
44 | redirectTo: '',
45 | pathMatch: 'full'
46 | }
47 | ];
48 |
49 | @NgModule({
50 | imports: [RouterModule.forRoot(routes)],
51 | providers: [
52 | AuthGuard,
53 | AdminGuard
54 | ],
55 | exports: [RouterModule]
56 | })
57 | export class AppRoutingModule { }
58 |
--------------------------------------------------------------------------------
/src/app/pages/admin/update-event/update-event.component.html:
--------------------------------------------------------------------------------
1 | {{ pageTitle }}
2 |
3 |
4 |
5 |
6 |
7 |
25 |
26 |
27 |
28 |
31 |
32 |
33 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Error: Event data could not be retrieved. View Admin Events.
43 |
44 |
45 |
--------------------------------------------------------------------------------
/src/assets/scss/_base.scss:
--------------------------------------------------------------------------------
1 | /*--------------------
2 | BASICS
3 | --------------------*/
4 |
5 | body {
6 | min-width: 320px;
7 | }
8 |
9 | /*-- Cursor --*/
10 |
11 | a,
12 | input[type=button],
13 | input[type=submit],
14 | button {
15 | cursor: pointer;
16 | }
17 |
18 | /*-- Link Buttons --*/
19 |
20 | .btn-link {
21 | color: #0275d8;
22 |
23 | &:hover {
24 | text-decoration: underline !important;
25 | }
26 | &:focus {
27 | box-shadow: none !important;
28 | color: #0275d8;
29 | text-decoration: none;
30 | }
31 | }
32 |
33 | /*-- Forms --*/
34 |
35 | input[type="text"],
36 | input[type="number"],
37 | input[type="password"],
38 | input[type="date"],
39 | select option,
40 | textarea {
41 | font-size: 16px; /* for iOS to prevent autozoom */
42 | }
43 | .formErrors {
44 | padding-top: 6px;
45 | }
46 | .ng-invalid.ng-dirty,
47 | .ng-invalid.ng-dirty:focus {
48 | border-color: #D9534E !important;
49 | }
50 | input::-webkit-input-placeholder { /* Chrome/Opera/Safari */
51 | color: rgba(0,0,0,.25) !important;
52 | opacity: 1 !important;
53 | }
54 | input::-moz-placeholder { /* Firefox 19+ */
55 | color: rgba(0,0,0,.25) !important;
56 | opacity: 1 !important;
57 | }
58 | input:-moz-placeholder { /* Firefox 18- */
59 | color: rgba(0,0,0,.25) !important;
60 | opacity: 1 !important;
61 | }
62 | input:-ms-input-placeholder { /* IE 10+ */
63 | color: rgba(0,0,0,.25) !important;
64 | opacity: 1 !important;
65 | }
66 |
67 | /*-- Helpers --*/
68 |
69 | .list-group-item > strong {
70 | padding-right: 5px;
71 | }
72 |
--------------------------------------------------------------------------------
/src/app/pages/admin/update-event/delete-event/delete-event.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnDestroy, Input } from '@angular/core';
2 | import { EventModel } from './../../../../core/models/event.model';
3 | import { Subscription } from 'rxjs';
4 | import { ApiService } from './../../../../core/api.service';
5 | import { Router } from '@angular/router';
6 |
7 | @Component({
8 | selector: 'app-delete-event',
9 | templateUrl: './delete-event.component.html',
10 | styleUrls: ['./delete-event.component.scss']
11 | })
12 | export class DeleteEventComponent implements OnDestroy {
13 | @Input() event: EventModel;
14 | confirmDelete: string;
15 | deleteSub: Subscription;
16 | submitting: boolean;
17 | error: boolean;
18 |
19 | constructor(
20 | private api: ApiService,
21 | private router: Router
22 | ) { }
23 |
24 | removeEvent() {
25 | this.submitting = true;
26 | // DELETE event by ID
27 | this.deleteSub = this.api
28 | .deleteEvent$(this.event._id)
29 | .subscribe(
30 | res => {
31 | this.submitting = false;
32 | this.error = false;
33 | console.log(res.message);
34 | // If successfully deleted event, redirect to Admin
35 | this.router.navigate(['/admin']);
36 | },
37 | err => {
38 | console.error(err);
39 | this.submitting = false;
40 | this.error = true;
41 | }
42 | );
43 | }
44 |
45 | ngOnDestroy() {
46 | if (this.deleteSub) {
47 | this.deleteSub.unsubscribe();
48 | }
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/src/app/core/core.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule, ModuleWithProviders } from '@angular/core';
2 | import { Title } from '@angular/platform-browser';
3 | import { CommonModule } from '@angular/common';
4 | import { HttpClientModule } from '@angular/common/http';
5 | import { RouterModule } from '@angular/router';
6 | import { FormsModule, ReactiveFormsModule } from '@angular/forms';
7 | import { DatePipe } from '@angular/common';
8 | import { ApiService } from './api.service';
9 | import { UtilsService } from './utils.service';
10 | import { FilterSortService } from './filter-sort.service';
11 | import { SubmittingComponent } from './forms/submitting.component';
12 | import { LoadingComponent } from './loading.component';
13 | import { HeaderComponent } from './../header/header.component';
14 | import { FooterComponent } from './../footer/footer.component';
15 |
16 | @NgModule({
17 | imports: [
18 | CommonModule,
19 | HttpClientModule,
20 | RouterModule,
21 | FormsModule,
22 | ReactiveFormsModule
23 | ],
24 | declarations: [
25 | HeaderComponent,
26 | FooterComponent,
27 | LoadingComponent,
28 | SubmittingComponent
29 | ],
30 | exports: [
31 | HttpClientModule,
32 | RouterModule,
33 | FormsModule,
34 | ReactiveFormsModule,
35 | HeaderComponent,
36 | FooterComponent,
37 | LoadingComponent,
38 | SubmittingComponent
39 | ]
40 | })
41 | export class CoreModule {
42 | static forRoot(): ModuleWithProviders {
43 | return {
44 | ngModule: CoreModule,
45 | providers: [
46 | Title,
47 | DatePipe,
48 | ApiService,
49 | UtilsService,
50 | FilterSortService
51 | ]
52 | };
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/app/header/header.component.html:
--------------------------------------------------------------------------------
1 |
50 |
--------------------------------------------------------------------------------
/src/app/pages/event/event.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ pageTitle }}
5 |
6 |
7 |
8 |
9 | This event is over.
10 |
11 |
12 |
13 |
14 |
32 |
33 |
34 |
37 |
38 |
39 |
43 |
44 |
45 |
46 |
47 |
48 | Oops! There was an error retrieving information for this event.
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/app/core/forms/date.validator.ts:
--------------------------------------------------------------------------------
1 | import { AbstractControl, ValidatorFn } from '@angular/forms';
2 | import { DATE_REGEX } from './formUtils.factory';
3 |
4 | export function dateValidator(): ValidatorFn {
5 | return (control: AbstractControl): {[key: string]: any} => {
6 | const dateStr = control.value;
7 | // First check for m/d/yyyy format
8 | // If pattern is wrong, don't validate yet
9 | if (!DATE_REGEX.test(dateStr)) {
10 | return null;
11 | }
12 | // Length of months (will update for leap years)
13 | const monthLengthArr = [ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
14 | // Object to return if date is invalid
15 | const invalidObj = { 'date': true };
16 | // Parse the date input to integers
17 | const dateArr = dateStr.split('/');
18 | const month = parseInt(dateArr[0], 10);
19 | const day = parseInt(dateArr[1], 10);
20 | const year = parseInt(dateArr[2], 10);
21 | // Today's date
22 | const now = new Date();
23 |
24 | // Validate year and month
25 | if (year < now.getFullYear() || year > 3000 || month === 0 || month > 12) {
26 | return invalidObj;
27 | }
28 | // Adjust for leap years
29 | if (year % 400 === 0 || (year % 100 !== 0 && year % 4 === 0)) {
30 | monthLengthArr[1] = 29;
31 | }
32 | // Validate day
33 | if (!(day > 0 && day <= monthLengthArr[month - 1])) {
34 | return invalidObj;
35 | };
36 | // If date is properly formatted, check the date vs today to ensure future
37 | // This is done this way to account for new Date() shifting invalid
38 | // date strings. This way we know the string is a correct date first.
39 | const date = new Date(dateStr);
40 | if (date <= now) {
41 | return invalidObj;
42 | }
43 | return null;
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/pages/my-rsvps/my-rsvps.component.html:
--------------------------------------------------------------------------------
1 | {{ pageTitle }}
2 |
3 | Hello, !
4 |
5 | You may create and administer events.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | You have not RSVPed to any events yet. Check out the homepage to see a list of upcoming events.
16 |
17 |
18 |
19 | You have RSVPed for the following upcoming events:
20 |
21 |
22 |
35 |
36 |
37 |
38 |
39 |
40 | Oops! There was an error getting your RSVP data.
41 |
42 |
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mean-rsvp-auth0",
3 | "version": "0.2.0",
4 | "license": "MIT",
5 | "scripts": {
6 | "ng": "ng",
7 | "start": "ng serve",
8 | "dev": "concurrently \"ng serve\" \"NODE_ENV=dev node server\"",
9 | "build": "ng build",
10 | "test": "ng test",
11 | "lint": "ng lint",
12 | "e2e": "ng e2e"
13 | },
14 | "private": true,
15 | "dependencies": {
16 | "@angular/animations": "6.1.3",
17 | "@angular/common": "6.1.3",
18 | "@angular/compiler": "6.1.3",
19 | "@angular/core": "6.1.3",
20 | "@angular/forms": "6.1.3",
21 | "@angular/platform-browser": "6.1.3",
22 | "@angular/platform-browser-dynamic": "6.1.3",
23 | "@angular/router": "6.1.3",
24 | "auth0-js": "^9.7.3",
25 | "body-parser": "^1.17.1",
26 | "core-js": "^2.4.1",
27 | "cors": "^2.8.3",
28 | "express": "^4.15.2",
29 | "express-jwt": "^5.3.1",
30 | "jwks-rsa": "^1.3.0",
31 | "method-override": "^2.3.8",
32 | "mongoose": "^5.0.11",
33 | "rxjs": "^6.2.2",
34 | "zone.js": "^0.8.26"
35 | },
36 | "devDependencies": {
37 | "@angular-devkit/build-angular": "~0.6.0",
38 | "@angular-devkit/core": "0.6.0",
39 | "@angular-devkit/schematics": "0.6.0",
40 | "@angular/cli": "6.1.4",
41 | "@angular/compiler-cli": "6.1.3",
42 | "@angular/language-service": "6.1.3",
43 | "@types/jasmine": "~2.5.53",
44 | "@types/jasminewd2": "~2.0.2",
45 | "@types/node": "~6.0.60",
46 | "ajv": "^6.1.1",
47 | "codelyzer": "4.3.0",
48 | "concurrently": "^3.5.1",
49 | "jasmine-core": "~2.6.2",
50 | "jasmine-spec-reporter": "~4.1.0",
51 | "karma": "~1.7.0",
52 | "karma-chrome-launcher": "~2.1.1",
53 | "karma-cli": "~1.0.1",
54 | "karma-coverage-istanbul-reporter": "^1.2.1",
55 | "karma-jasmine": "~1.1.0",
56 | "karma-jasmine-html-reporter": "^0.2.2",
57 | "protractor": "^5.4.0",
58 | "ts-node": "~3.2.0",
59 | "tslint": "5.10.0",
60 | "typescript": "2.9.2"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/app/pages/my-rsvps/my-rsvps.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, OnDestroy } from '@angular/core';
2 | import { Title } from '@angular/platform-browser';
3 | import { AuthService } from './../../auth/auth.service';
4 | import { ApiService } from './../../core/api.service';
5 | import { UtilsService } from './../../core/utils.service';
6 | import { FilterSortService } from './../../core/filter-sort.service';
7 | import { Subscription } from 'rxjs';
8 | import { EventModel } from './../../core/models/event.model';
9 |
10 | @Component({
11 | selector: 'app-my-rsvps',
12 | templateUrl: './my-rsvps.component.html',
13 | styleUrls: ['./my-rsvps.component.scss']
14 | })
15 | export class MyRsvpsComponent implements OnInit, OnDestroy {
16 | pageTitle = 'My RSVPs';
17 | loggedInSub: Subscription;
18 | eventListSub: Subscription;
19 | eventList: EventModel[];
20 | loading: boolean;
21 | error: boolean;
22 | userIdp: string;
23 |
24 | constructor(
25 | private title: Title,
26 | public auth: AuthService,
27 | private api: ApiService,
28 | public fs: FilterSortService,
29 | public utils: UtilsService
30 | ) { }
31 |
32 | ngOnInit() {
33 | this.loggedInSub = this.auth.loggedIn$.subscribe(
34 | loggedIn => {
35 | this.loading = true;
36 | if (loggedIn) {
37 | this._getEventList();
38 | }
39 | }
40 | );
41 | this.title.setTitle(this.pageTitle);
42 | }
43 |
44 | private _getEventList() {
45 | // Get events user has RSVPed to
46 | this.eventListSub = this.api
47 | .getUserEvents$(this.auth.userProfile.sub)
48 | .subscribe(
49 | res => {
50 | this.eventList = res;
51 | this.loading = false;
52 | },
53 | err => {
54 | console.error(err);
55 | this.loading = false;
56 | this.error = true;
57 | }
58 | );
59 | }
60 |
61 | ngOnDestroy() {
62 | this.loggedInSub.unsubscribe();
63 | this.eventListSub.unsubscribe();
64 | }
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/src/app/pages/admin/admin.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, OnDestroy } from '@angular/core';
2 | import { Title } from '@angular/platform-browser';
3 | import { AuthService } from './../../auth/auth.service';
4 | import { ApiService } from './../../core/api.service';
5 | import { UtilsService } from './../../core/utils.service';
6 | import { FilterSortService } from './../../core/filter-sort.service';
7 | import { Subscription } from 'rxjs';
8 | import { EventModel } from './../../core/models/event.model';
9 |
10 | @Component({
11 | selector: 'app-admin',
12 | templateUrl: './admin.component.html',
13 | styleUrls: ['./admin.component.scss']
14 | })
15 | export class AdminComponent implements OnInit, OnDestroy {
16 | pageTitle = 'Admin';
17 | loggedInSub: Subscription;
18 | eventsSub: Subscription;
19 | eventList: EventModel[];
20 | filteredEvents: EventModel[];
21 | loading: boolean;
22 | error: boolean;
23 | query = '';
24 |
25 | constructor(
26 | private title: Title,
27 | public auth: AuthService,
28 | private api: ApiService,
29 | public utils: UtilsService,
30 | public fs: FilterSortService
31 | ) { }
32 |
33 | ngOnInit() {
34 | this.title.setTitle(this.pageTitle);
35 | this._getEventList();
36 | }
37 |
38 | private _getEventList() {
39 | // Get all (admin) events
40 | this.eventsSub = this.api
41 | .getAdminEvents$()
42 | .subscribe(
43 | res => {
44 | this.eventList = res;
45 | this.filteredEvents = res;
46 | this.loading = false;
47 | },
48 | err => {
49 | console.error(err);
50 | this.loading = false;
51 | this.error = true;
52 | }
53 | );
54 | }
55 |
56 | searchEvents() {
57 | this.filteredEvents = this.fs.search(this.eventList, this.query, '_id', 'mediumDate');
58 | }
59 |
60 | resetQuery() {
61 | this.query = '';
62 | this.filteredEvents = this.eventList;
63 | }
64 |
65 | ngOnDestroy() {
66 | this.eventsSub.unsubscribe();
67 | }
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/src/app/pages/home/home.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, OnDestroy } from '@angular/core';
2 | import { Title } from '@angular/platform-browser';
3 | import { ApiService } from './../../core/api.service';
4 | import { UtilsService } from './../../core/utils.service';
5 | import { FilterSortService } from './../../core/filter-sort.service';
6 | import { Subscription } from 'rxjs';
7 | import { EventModel } from './../../core/models/event.model';
8 |
9 | @Component({
10 | selector: 'app-home',
11 | templateUrl: './home.component.html',
12 | styleUrls: ['./home.component.scss']
13 | })
14 | export class HomeComponent implements OnInit, OnDestroy {
15 | pageTitle = 'Events';
16 | eventListSub: Subscription;
17 | eventList: EventModel[];
18 | filteredEvents: EventModel[];
19 | loading: boolean;
20 | error: boolean;
21 | query: '';
22 |
23 | constructor(
24 | private title: Title,
25 | public utils: UtilsService,
26 | private api: ApiService,
27 | public fs: FilterSortService
28 | ) { }
29 |
30 | ngOnInit() {
31 | this.title.setTitle(this.pageTitle);
32 | this._getEventList();
33 | }
34 |
35 | private _getEventList() {
36 | this.loading = true;
37 | // Get future, public events
38 | this.eventListSub = this.api
39 | .getEvents$()
40 | .subscribe(
41 | res => {
42 | this.eventList = res;
43 | this.filteredEvents = res;
44 | this.loading = false;
45 | },
46 | err => {
47 | console.error(err);
48 | this.loading = false;
49 | this.error = true;
50 | }
51 | );
52 | }
53 |
54 | searchEvents() {
55 | this.filteredEvents = this.fs.search(this.eventList, this.query, '_id', 'mediumDate');
56 | }
57 |
58 | resetQuery() {
59 | this.query = '';
60 | this.filteredEvents = this.eventList;
61 | }
62 |
63 | get noSearchResults(): boolean {
64 | return !!(!this.filteredEvents.length && this.query);
65 | }
66 |
67 | ngOnDestroy() {
68 | this.eventListSub.unsubscribe();
69 | }
70 |
71 | }
72 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------
3 | | Dependencies
4 | |--------------------------------------
5 | */
6 |
7 | // Modules
8 | const express = require('express');
9 | const path = require('path');
10 | const bodyParser = require('body-parser');
11 | const mongoose = require('mongoose');
12 | const methodOverride = require('method-override');
13 | const cors = require('cors');
14 | // Config
15 | const config = require('./server/config');
16 |
17 | /*
18 | |--------------------------------------
19 | | MongoDB
20 | |--------------------------------------
21 | */
22 |
23 | mongoose.connect(config.MONGO_URI);
24 | const monDb = mongoose.connection;
25 |
26 | monDb.on('error', function() {
27 | console.error('MongoDB Connection Error. Please make sure that', config.MONGO_URI, 'is running.');
28 | });
29 |
30 | monDb.once('open', function callback() {
31 | console.info('Connected to MongoDB:', config.MONGO_URI);
32 | });
33 |
34 | /*
35 | |--------------------------------------
36 | | App
37 | |--------------------------------------
38 | */
39 |
40 | const app = express();
41 |
42 | app.use(bodyParser.json());
43 | app.use(bodyParser.urlencoded({ extended: false }));
44 | app.use(methodOverride('X-HTTP-Method-Override'));
45 | app.use(cors());
46 |
47 | // Set port
48 | const port = process.env.PORT || '8083';
49 | app.set('port', port);
50 |
51 | // Set static path to Angular app in dist
52 | // Don't run in dev
53 | if (process.env.NODE_ENV !== 'dev') {
54 | app.use('/', express.static(path.join(__dirname, './dist')));
55 | }
56 |
57 | /*
58 | |--------------------------------------
59 | | Routes
60 | |--------------------------------------
61 | */
62 |
63 | require('./server/api')(app, config);
64 |
65 | // Pass routing to Angular app
66 | // Don't run in dev
67 | if (process.env.NODE_ENV !== 'dev') {
68 | app.get('*', function(req, res) {
69 | res.sendFile(path.join(__dirname, '/dist/index.html'));
70 | });
71 | }
72 |
73 | /*
74 | |--------------------------------------
75 | | Server
76 | |--------------------------------------
77 | */
78 |
79 | app.listen(port, () => console.log(`Server running on localhost:${port}`));
80 |
--------------------------------------------------------------------------------
/src/app/pages/home/home.component.html:
--------------------------------------------------------------------------------
1 | {{ pageTitle }}
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
26 |
27 |
28 |
29 | No events found for {{ query }}, sorry!
30 |
31 |
32 |
33 |
44 |
45 |
46 |
47 |
48 | No upcoming public events available.
49 |
50 |
51 |
52 |
53 |
54 | Oops! There was an error retrieving event data.
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/src/app/pages/admin/update-event/update-event.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, OnDestroy } from '@angular/core';
2 | import { Title } from '@angular/platform-browser';
3 | import { AuthService } from './../../../auth/auth.service';
4 | import { ApiService } from './../../../core/api.service';
5 | import { UtilsService } from './../../../core/utils.service';
6 | import { ActivatedRoute } from '@angular/router';
7 | import { Subscription } from 'rxjs';
8 | import { EventModel } from './../../../core/models/event.model';
9 |
10 | @Component({
11 | selector: 'app-update-event',
12 | templateUrl: './update-event.component.html',
13 | styleUrls: ['./update-event.component.scss']
14 | })
15 | export class UpdateEventComponent implements OnInit, OnDestroy {
16 | pageTitle = 'Update Event';
17 | routeSub: Subscription;
18 | eventSub: Subscription;
19 | event: EventModel;
20 | loading: boolean;
21 | submitting: boolean;
22 | error: boolean;
23 | tabSub: Subscription;
24 | tab: string;
25 | private _id: string;
26 |
27 | constructor(
28 | private route: ActivatedRoute,
29 | public auth: AuthService,
30 | private api: ApiService,
31 | public utils: UtilsService,
32 | private title: Title
33 | ) { }
34 |
35 | ngOnInit() {
36 | this.title.setTitle(this.pageTitle);
37 |
38 | // Set event ID from route params and subscribe
39 | this.routeSub = this.route.params
40 | .subscribe(params => {
41 | this._id = params['id'];
42 | this._getEvent();
43 | });
44 |
45 | // Subscribe to query params to watch for tab changes
46 | this.tabSub = this.route.queryParams
47 | .subscribe(queryParams => {
48 | this.tab = queryParams['tab'] || 'edit';
49 | });
50 | }
51 |
52 | private _getEvent() {
53 | this.loading = true;
54 | // GET event by ID
55 | this.eventSub = this.api
56 | .getEventById$(this._id)
57 | .subscribe(
58 | res => {
59 | this.event = res;
60 | this.loading = false;
61 | },
62 | err => {
63 | console.error(err);
64 | this.loading = false;
65 | this.error = true;
66 | }
67 | );
68 | }
69 |
70 | ngOnDestroy() {
71 | this.routeSub.unsubscribe();
72 | this.tabSub.unsubscribe();
73 | this.eventSub.unsubscribe();
74 | }
75 |
76 | }
77 |
--------------------------------------------------------------------------------
/src/app/core/utils.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { DatePipe } from '@angular/common';
3 |
4 | @Injectable()
5 | export class UtilsService {
6 |
7 | constructor(private datePipe: DatePipe) { }
8 |
9 | isLoaded(loading: boolean): boolean {
10 | return loading === false;
11 | }
12 |
13 | eventDates(start, end): string {
14 | // Display single-day events as "Jan 7, 2018"
15 | // Display multi-day events as "Aug 12, 2017 - Aug 13, 2017"
16 | const startDate = this.datePipe.transform(start, 'mediumDate');
17 | const endDate = this.datePipe.transform(end, 'mediumDate');
18 |
19 | if (startDate === endDate) {
20 | return startDate;
21 | } else {
22 | return `${startDate} - ${endDate}`;
23 | }
24 | }
25 |
26 | eventDatesTimes(start, end): string {
27 | // Display single-day events as "1/7/2018, 5:30 PM - 7:30 PM"
28 | // Display multi-day events as "8/12/2017, 8:00 PM - 8/13/2017, 10:00 AM"
29 | const _shortDate = 'M/d/yyyy';
30 | const startDate = this.datePipe.transform(start, _shortDate);
31 | const startTime = this.datePipe.transform(start, 'shortTime');
32 | const endDate = this.datePipe.transform(end, _shortDate);
33 | const endTime = this.datePipe.transform(end, 'shortTime');
34 |
35 | if (startDate === endDate) {
36 | return `${startDate}, ${startTime} - ${endTime}`;
37 | } else {
38 | return `${startDate}, ${startTime} - ${endDate}, ${endTime}`;
39 | }
40 | }
41 |
42 | eventPast(eventEnd): boolean {
43 | // Check if event has already ended
44 | const now = new Date();
45 | const then = new Date(eventEnd.toString());
46 | return now >= then;
47 | }
48 |
49 | tabIs(currentTab: string, tab: string): boolean {
50 | // Check if current tab is tab name
51 | return currentTab === tab;
52 | }
53 |
54 | displayCount(guests: number): string {
55 | // Example usage:
56 | // {{displayCount(guests)}} attending this event
57 | const persons = guests === 1 ? ' person' : ' people';
58 | return guests + persons;
59 | }
60 |
61 | showPlusOnes(guests: number): string {
62 | // If bringing additional guest(s), show as "+n"
63 | if (guests) {
64 | return `+${guests}`;
65 | }
66 | }
67 |
68 | booleanToText(bool: boolean): string {
69 | // Change a boolean to 'Yes' or 'No' string
70 | return bool ? 'Yes' : 'No';
71 | }
72 |
73 | capitalize(str: string): string {
74 | // Capitalize first letter of string
75 | return str.charAt(0).toUpperCase() + str.slice(1);
76 | }
77 |
78 | }
79 |
--------------------------------------------------------------------------------
/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/docs/ts/latest/guide/browser-support.html
15 | */
16 |
17 | (window as any).global = window;
18 |
19 | /***************************************************************************************************
20 | * BROWSER POLYFILLS
21 | */
22 |
23 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/
24 | // import 'core-js/es6/symbol';
25 | // import 'core-js/es6/object';
26 | // import 'core-js/es6/function';
27 | // import 'core-js/es6/parse-int';
28 | // import 'core-js/es6/parse-float';
29 | // import 'core-js/es6/number';
30 | // import 'core-js/es6/math';
31 | // import 'core-js/es6/string';
32 | // import 'core-js/es6/date';
33 | // import 'core-js/es6/array';
34 | // import 'core-js/es6/regexp';
35 | // import 'core-js/es6/map';
36 | // import 'core-js/es6/set';
37 |
38 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */
39 | // import 'classlist.js'; // Run `npm install --save classlist.js`.
40 |
41 | /** IE10 and IE11 requires the following to support `@angular/animation`. */
42 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`.
43 |
44 |
45 | /** Evergreen browsers require these. **/
46 | import 'core-js/es6/reflect';
47 | import 'core-js/es7/reflect';
48 |
49 |
50 | /** ALL Firefox browsers require the following to support `@angular/animation`. **/
51 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`.
52 |
53 |
54 |
55 | /***************************************************************************************************
56 | * Zone JS is required by Angular itself.
57 | */
58 | import 'zone.js/dist/zone'; // Included with Angular CLI.
59 |
60 |
61 |
62 | /***************************************************************************************************
63 | * APPLICATION IMPORTS
64 | */
65 |
66 | /**
67 | * Date, currency, decimal and percent pipes.
68 | * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10
69 | */
70 | // import 'intl'; // Run `npm install --save intl`.
71 |
--------------------------------------------------------------------------------
/src/app/pages/event/event.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, OnDestroy } from '@angular/core';
2 | import { Title } from '@angular/platform-browser';
3 | import { AuthService } from './../../auth/auth.service';
4 | import { ApiService } from './../../core/api.service';
5 | import { UtilsService } from './../../core/utils.service';
6 | import { ActivatedRoute } from '@angular/router';
7 | import { Subscription } from 'rxjs';
8 | import { EventModel } from './../../core/models/event.model';
9 |
10 | @Component({
11 | selector: 'app-event',
12 | templateUrl: './event.component.html',
13 | styleUrls: ['./event.component.scss']
14 | })
15 | export class EventComponent implements OnInit, OnDestroy {
16 | pageTitle: string;
17 | id: string;
18 | loggedInSub: Subscription;
19 | routeSub: Subscription;
20 | tabSub: Subscription;
21 | eventSub: Subscription;
22 | event: EventModel;
23 | loading: boolean;
24 | error: boolean;
25 | tab: string;
26 | eventPast: boolean;
27 |
28 | constructor(
29 | private route: ActivatedRoute,
30 | public auth: AuthService,
31 | private api: ApiService,
32 | public utils: UtilsService,
33 | private title: Title
34 | ) { }
35 |
36 | ngOnInit() {
37 | this.loggedInSub = this.auth.loggedIn$.subscribe(
38 | loggedIn => {
39 | this.loading = true;
40 | if (loggedIn) {
41 | this._routeSubs();
42 | }
43 | }
44 | );
45 | }
46 |
47 | private _routeSubs() {
48 | // Set event ID from route params and subscribe
49 | this.routeSub = this.route.params
50 | .subscribe(params => {
51 | this.id = params['id'];
52 | this._getEvent();
53 | });
54 |
55 | // Subscribe to query params to watch for tab changes
56 | this.tabSub = this.route.queryParams
57 | .subscribe(queryParams => {
58 | this.tab = queryParams['tab'] || 'details';
59 | });
60 | }
61 |
62 | private _getEvent() {
63 | // GET event by ID
64 | this.eventSub = this.api
65 | .getEventById$(this.id)
66 | .subscribe(
67 | res => {
68 | this.event = res;
69 | this._setPageTitle(this.event.title);
70 | this.loading = false;
71 | this.eventPast = this.utils.eventPast(this.event.endDatetime);
72 | },
73 | err => {
74 | console.error(err);
75 | this.loading = false;
76 | this.error = true;
77 | this._setPageTitle('Event Details');
78 | }
79 | );
80 | }
81 |
82 | private _setPageTitle(title: string) {
83 | this.pageTitle = title;
84 | this.title.setTitle(title);
85 | }
86 |
87 | ngOnDestroy() {
88 | this.routeSub.unsubscribe();
89 | this.tabSub.unsubscribe();
90 | this.eventSub.unsubscribe();
91 | }
92 |
93 | }
94 |
--------------------------------------------------------------------------------
/src/app/pages/admin/event-form/event-form.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 |
3 | @Injectable()
4 | export class EventFormService {
5 | validationMessages: any;
6 | // Set up errors object
7 | formErrors = {
8 | title: '',
9 | location: '',
10 | viewPublic: '',
11 | description: '',
12 | datesGroup: {
13 | startDate: '',
14 | startTime: '',
15 | endDate: '',
16 | endTime: '',
17 | }
18 | };
19 | // Min/maxlength validation
20 | textMin = 3;
21 | titleMax = 36;
22 | locMax = 200;
23 | dateMax = 10;
24 | timeMax = 8;
25 | descMax = 2000;
26 | // Formats
27 | dateFormat = 'm/d/yyyy';
28 | timeFormat = 'h:mm AM/PM';
29 |
30 | constructor() {
31 | this.validationMessages = {
32 | title: {
33 | required: `Title is required.`,
34 | minlength: `Title must be ${this.textMin} characters or more.`,
35 | maxlength: `Title must be ${this.titleMax} characters or less.`
36 | },
37 | location: {
38 | required: `Location is required.`,
39 | minlength: `Location must be ${this.textMin} characters or more.`,
40 | maxlength: `Location must be ${this.locMax} characters or less.`
41 | },
42 | startDate: {
43 | required: `Start date is required.`,
44 | maxlength: `Start date cannot be longer than ${this.dateMax} characters.`,
45 | pattern: `Start date must be in the format ${this.dateFormat}.`,
46 | date: `Start date must be a valid date at least one day in the future.`
47 | },
48 | startTime: {
49 | required: `Start time is required.`,
50 | pattern: `Start time must be a valid time in the format ${this.timeFormat}.`,
51 | maxlength: `Start time must be ${this.timeMax} characters or less.`
52 | },
53 | endDate: {
54 | required: `End date is required.`,
55 | maxlength: `End date cannot be longer than ${this.dateMax} characters.`,
56 | pattern: `End date must be in the format ${this.dateFormat}.`,
57 | date: `End date must be a valid date at least one day in the future.`
58 | },
59 | endTime: {
60 | required: `End time is required.`,
61 | pattern: `End time must be a valid time in the format ${this.timeFormat}.`,
62 | maxlength: `End time must be ${this.timeMax} characters or less.`
63 | },
64 | viewPublic: {
65 | required: `You must specify whether this event should be publicly listed.`
66 | },
67 | description: {
68 | maxlength: `Description must be ${this.descMax} characters or less.`
69 | }
70 | };
71 | }
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/src/assets/images/loading.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/header/header.component.scss:
--------------------------------------------------------------------------------
1 | /*--------------------
2 | HEADER
3 | --------------------*/
4 |
5 | @import '../../assets/scss/partials/layout.vars';
6 |
7 | /*-- Navigation --*/
8 |
9 | .nav {
10 | background: #eee;
11 | backface-visibility: hidden;
12 | -webkit-backface-visibility: hidden;
13 | box-shadow: inset -8px 0 8px -6px rgba(0,0,0,0.2);
14 | display: none; /* deal with FOUC */
15 | height: 100%;
16 | overflow-y: auto;
17 | padding: $padding-screen-small;
18 | position: absolute;
19 | top: 0;
20 | transform: translate3d(-100%,0,0);
21 | width: 270px;
22 |
23 | :host-context(.nav-closed) &,
24 | :host-context(.nav-open) & {
25 | display: block; /* deal with FOUC */
26 | }
27 | .active {
28 | font-weight: bold;
29 | }
30 | &-list {
31 | list-style: none;
32 | margin-bottom: 0;
33 | padding-left: 0;
34 |
35 | a {
36 | display: block;
37 | padding: 6px;
38 |
39 | &:hover,
40 | &:active,
41 | &:focus {
42 | text-decoration: none;
43 | }
44 | }
45 | }
46 | }
47 |
48 | /*-- Hamburger toggle --*/
49 |
50 | .toggle-offcanvas {
51 | border-right: 1px solid rgba(255,255,255,.5);
52 | display: inline-block;
53 | height: 50px;
54 | padding: 23.5px 13px;
55 | position: relative;
56 | text-align: center;
57 | width: 50px;
58 | z-index: 100;
59 |
60 | span,
61 | span:before,
62 | span:after {
63 | background: #fff;
64 | border-radius: 1px;
65 | content: '';
66 | display: block;
67 | height: 3px;
68 | position: absolute;
69 | transition: all 250ms ease-in-out;
70 | width: 24px;
71 | }
72 | span {
73 | &:before {
74 | top: -9px;
75 | }
76 | &:after {
77 | bottom: -9px;
78 | }
79 | }
80 | :host-context(.nav-open) & {
81 | span {
82 | background-color: transparent;
83 |
84 | &:before,
85 | &:after {
86 | top: 0;
87 | }
88 | &:before {
89 | transform: rotate(45deg);
90 | }
91 | &:after {
92 | transform: rotate(-45deg);
93 | }
94 | }
95 | }
96 | }
97 |
98 | /*-- Header and title --*/
99 |
100 | .header-page {
101 | color: #fff;
102 | height: 50px;
103 | margin-bottom: 10px;
104 | position: relative;
105 |
106 | &-siteTitle {
107 | font-size: 30px;
108 | line-height: 50px;
109 | margin: 0;
110 | padding: 0 0 0 60px;
111 | position: absolute;
112 | top: 0;
113 | width: 100%;
114 | }
115 | a {
116 | color: #fff;
117 | text-decoration: none;
118 | }
119 | &-authStatus {
120 | color: #fff;
121 | font-size: 12px;
122 | line-height: 50px;
123 | padding: 0 10px;
124 | position: absolute;
125 | right: 0; top: 0;
126 |
127 | a:hover {
128 | text-decoration: underline;
129 | }
130 | .divider {
131 | display: inline-block;
132 | opacity: .5;
133 | padding: 0 4px;
134 | }
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MEAN-RSVP-Auth0
2 |
3 | This is the sample repository for the Real-World Angular Series of tutorials. Begin the tutorials here: [Real-World Angular Series - Part 1](https://auth0.com/blog/real-world-angular-series-part-1).
4 |
5 | ## Requirements
6 |
7 | * [Node + npm](https://nodejs.org/)
8 | * [Angular CLI](https://cli.angular.io/) v6+
9 | * [Auth0 account](https://auth0.com) with [application](https://manage.auth0.com/#/applications)
10 | * [mLab](https://mlab.com) MongoDB database
11 |
12 | This repo is intended to be supplemental to the tutorials. Reference the tutorials for full implementation details.
13 |
14 | ## Install
15 |
16 | Clone this repository, then run:
17 |
18 | ```
19 | $ npm install
20 | ```
21 |
22 | ## Setup
23 |
24 | * Add your Auth0 and MongoDB credentials and remove `.SAMPLE` extension: `server/config.js.SAMPLE`
25 | * Add your Auth0 credentials and remove `.SAMPLE` extension: `src/app/auth/auth.config.ts.SAMPLE`
26 |
27 | ## Development server
28 |
29 | ```bash
30 | $ npm run dev
31 | ```
32 |
33 | App available at `http://localhost:4200`.
34 |
35 | Server available at `http://localhost:8083/api`.
36 |
37 | ## Build (local)
38 |
39 | ```
40 | $ ng build --prod // client
41 | $ node server // server
42 | ```
43 |
44 | App and server both available on `http://localhost:8083`.
45 |
46 | ## Deploy
47 |
48 | To deploy the app in this repo to a production environment, follow the instructions here: [Real-World Angular Series - Part 8](https://auth0.com/blog/real-world-angular-series-part-8/#deploy).
49 |
50 | ## What is Auth0?
51 |
52 | Auth0 helps you to:
53 |
54 | * Add authentication with [multiple authentication sources](https://docs.auth0.com/identityproviders), either social like **Google, Facebook, Microsoft Account, LinkedIn, GitHub, Twitter, Box, Salesforce, amont others**, or enterprise identity systems like **Windows Azure AD, Google Apps, Active Directory, ADFS or any SAML Identity Provider**.
55 | * Add authentication through more traditional **[username/password databases](https://docs.auth0.com/mysql-connection-tutorial)**.
56 | * Add support for **[linking different user accounts](https://docs.auth0.com/link-accounts)** with the same user.
57 | * Support for generating signed [Json Web Tokens](https://docs.auth0.com/jwt) to call your APIs and **flow the user identity** securely.
58 | * Analytics of how, when and where users are logging in.
59 | * Pull data from other sources and add it to the user profile, through [JavaScript rules](https://docs.auth0.com/rules).
60 |
61 | ## Create a Free Auth0 Account
62 |
63 | 1. Go to [Auth0](https://auth0.com) and click Sign Up.
64 | 2. Use Google, GitHub, or Microsoft Account to log in.
65 |
66 | ## Issue Reporting
67 |
68 | If you have found a bug or if you have a feature request, please report them at this repository issues section. Please do not report security vulnerabilities on the public GitHub issue tracker. The [Responsible Disclosure Program](https://auth0.com/whitehat) details the procedure for disclosing security issues.
69 |
70 | ## Author
71 |
72 | [Auth0](auth0.com)
73 |
74 | ## License
75 |
76 | This project is licensed under the MIT license. See the [LICENSE](LICENSE) file for more info.
77 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "rulesDirectory": [
3 | "node_modules/codelyzer"
4 | ],
5 | "rules": {
6 | "callable-types": true,
7 | "class-name": true,
8 | "comment-format": [
9 | true,
10 | "check-space"
11 | ],
12 | "curly": true,
13 | "eofline": true,
14 | "forin": true,
15 | "import-blacklist": [true],
16 | "import-spacing": true,
17 | "indent": [
18 | true,
19 | "spaces"
20 | ],
21 | "interface-over-type-literal": true,
22 | "label-position": true,
23 | "max-line-length": [
24 | true,
25 | 140
26 | ],
27 | "member-access": false,
28 | "member-ordering": [
29 | true,
30 | "static-before-instance",
31 | "variables-before-functions"
32 | ],
33 | "no-arg": true,
34 | "no-bitwise": true,
35 | "no-console": [
36 | true,
37 | "debug",
38 | "info",
39 | "time",
40 | "timeEnd",
41 | "trace"
42 | ],
43 | "no-construct": true,
44 | "no-debugger": true,
45 | "no-duplicate-variable": true,
46 | "no-empty": false,
47 | "no-empty-interface": true,
48 | "no-eval": true,
49 | "no-inferrable-types": [true, "ignore-params"],
50 | "no-shadowed-variable": true,
51 | "no-string-literal": false,
52 | "no-string-throw": true,
53 | "no-switch-case-fall-through": true,
54 | "no-trailing-whitespace": true,
55 | "no-unused-expression": true,
56 | "no-use-before-declare": true,
57 | "no-var-keyword": true,
58 | "object-literal-sort-keys": false,
59 | "one-line": [
60 | true,
61 | "check-open-brace",
62 | "check-catch",
63 | "check-else",
64 | "check-whitespace"
65 | ],
66 | "prefer-const": true,
67 | "quotemark": [
68 | true,
69 | "single"
70 | ],
71 | "radix": true,
72 | "semicolon": [
73 | "always"
74 | ],
75 | "triple-equals": [
76 | true,
77 | "allow-null-check"
78 | ],
79 | "typedef-whitespace": [
80 | true,
81 | {
82 | "call-signature": "nospace",
83 | "index-signature": "nospace",
84 | "parameter": "nospace",
85 | "property-declaration": "nospace",
86 | "variable-declaration": "nospace"
87 | }
88 | ],
89 | "typeof-compare": true,
90 | "unified-signatures": true,
91 | "variable-name": false,
92 | "whitespace": [
93 | true,
94 | "check-branch",
95 | "check-decl",
96 | "check-operator",
97 | "check-separator",
98 | "check-type"
99 | ],
100 |
101 | "directive-selector": [true, "attribute", "app", "camelCase"],
102 | "component-selector": [true, "element", "app", "kebab-case"],
103 | "use-input-property-decorator": true,
104 | "use-output-property-decorator": true,
105 | "use-host-property-decorator": true,
106 | "no-input-rename": true,
107 | "no-output-rename": true,
108 | "use-life-cycle-interface": true,
109 | "use-pipe-transform-interface": true,
110 | "component-class-suffix": true,
111 | "directive-class-suffix": true,
112 | "no-access-missing-member": true,
113 | "templates-use-public": true,
114 | "invoke-injectable": true
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/app/pages/admin/admin.component.html:
--------------------------------------------------------------------------------
1 | {{ pageTitle }}
2 |
3 |
4 |
5 | Welcome, {{ auth.userProfile?.name }}! You can create and administer events below.
6 |
7 |
8 | + Create New Event
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
35 |
36 |
37 |
38 | No events found for {{ query }}, sorry!
39 |
40 |
41 |
42 |
43 |
46 |
47 |
48 |
49 |
50 |
51 |

56 |

61 |
62 |
63 |
64 | Date: {{ utils.eventDates(event.startDatetime, event.endDatetime) }}
65 |
66 |
67 | Edit
70 | Delete
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | No events have been created yet.
82 |
83 |
84 |
85 |
86 |
87 | Oops! There was an error retrieving event data.
88 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/src/app/pages/event/rsvp/rsvp-form/rsvp-form.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core';
2 | import { AuthService } from './../../../../auth/auth.service';
3 | import { Subscription } from 'rxjs';
4 | import { ApiService } from './../../../../core/api.service';
5 | import { RsvpModel } from './../../../../core/models/rsvp.model';
6 | import { GUESTS_REGEX } from './../../../../core/forms/formUtils.factory';
7 |
8 | @Component({
9 | selector: 'app-rsvp-form',
10 | templateUrl: './rsvp-form.component.html',
11 | styleUrls: ['./rsvp-form.component.scss']
12 | })
13 | export class RsvpFormComponent implements OnInit, OnDestroy {
14 | @Input() eventId: string;
15 | @Input() rsvp: RsvpModel;
16 | @Output() submitRsvp = new EventEmitter();
17 | GUESTS_REGEX = GUESTS_REGEX;
18 | isEdit: boolean;
19 | formRsvp: RsvpModel;
20 | submitRsvpSub: Subscription;
21 | submitting: boolean;
22 | error: boolean;
23 |
24 | constructor(
25 | private auth: AuthService,
26 | private api: ApiService
27 | ) { }
28 |
29 | ngOnInit() {
30 | this.isEdit = !!this.rsvp;
31 | this._setFormRsvp();
32 | }
33 |
34 | private _setFormRsvp() {
35 | if (!this.isEdit) {
36 | // If creating a new RSVP,
37 | // create new RsvpModel with default data
38 | this.formRsvp = new RsvpModel(
39 | this.auth.userProfile.sub,
40 | this.auth.userProfile.name,
41 | this.eventId,
42 | null,
43 | 0);
44 | } else {
45 | // If editing an existing RSVP,
46 | // create new RsvpModel from existing data
47 | this.formRsvp = new RsvpModel(
48 | this.rsvp.userId,
49 | this.rsvp.name,
50 | this.rsvp.eventId,
51 | this.rsvp.attending,
52 | this.rsvp.guests,
53 | this.rsvp.comments,
54 | this.rsvp._id
55 | );
56 | }
57 | }
58 |
59 | changeAttendanceSetGuests() {
60 | // If attendance changed to no, set guests: 0
61 | if (!this.formRsvp.attending) {
62 | this.formRsvp.guests = 0;
63 | }
64 | }
65 |
66 | onSubmit() {
67 | this.submitting = true;
68 | if (!this.isEdit) {
69 | this.submitRsvpSub = this.api
70 | .postRsvp$(this.formRsvp)
71 | .subscribe(
72 | data => this._handleSubmitSuccess(data),
73 | err => this._handleSubmitError(err)
74 | );
75 | } else {
76 | this.submitRsvpSub = this.api
77 | .editRsvp$(this.rsvp._id, this.formRsvp)
78 | .subscribe(
79 | data => this._handleSubmitSuccess(data),
80 | err => this._handleSubmitError(err)
81 | );
82 | }
83 | }
84 |
85 | private _handleSubmitSuccess(res) {
86 | const eventObj = {
87 | isEdit: this.isEdit,
88 | rsvp: res
89 | };
90 | this.submitRsvp.emit(eventObj);
91 | this.error = false;
92 | this.submitting = false;
93 | }
94 |
95 | private _handleSubmitError(err) {
96 | const eventObj = {
97 | isEdit: this.isEdit,
98 | error: err
99 | };
100 | this.submitRsvp.emit(eventObj);
101 | console.error(err);
102 | this.submitting = false;
103 | this.error = true;
104 | }
105 |
106 | ngOnDestroy() {
107 | if (this.submitRsvpSub) {
108 | this.submitRsvpSub.unsubscribe();
109 | }
110 | }
111 |
112 | }
113 |
--------------------------------------------------------------------------------
/src/app/pages/event/rsvp/rsvp-form/rsvp-form.component.html:
--------------------------------------------------------------------------------
1 |
108 |
--------------------------------------------------------------------------------
/src/app/core/filter-sort.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { DatePipe } from '@angular/common';
3 |
4 | @Injectable()
5 | export class FilterSortService {
6 |
7 | constructor(private datePipe: DatePipe) { }
8 |
9 | private _objArrayCheck(array: any[]): boolean {
10 | // Checks if the first item in the array is an object
11 | // (assumes same-shape for all array items)
12 | // Necessary because some arrays passed in may have
13 | // models that don't match {[key: string]: any}[]
14 | // This check prevents uncaught reference errors
15 | const item0 = array[0];
16 | const check = !!(array.length && item0 !== null && Object.prototype.toString.call(item0) === '[object Object]');
17 | return check;
18 | }
19 |
20 | filter(array: any[], property: string, value: any) {
21 | // Return only items with specific key/value pair
22 | if (!property || value === undefined || !this._objArrayCheck(array)) {
23 | return array;
24 | }
25 | const filteredArray = array.filter(item => {
26 | for (const key in item) {
27 | if (item.hasOwnProperty(key)) {
28 | if (key === property && item[key] === value) {
29 | return true;
30 | }
31 | }
32 | }
33 | });
34 | return filteredArray;
35 | }
36 |
37 | search(array: any[], query: string, excludeProps?: string|string[], dateFormat?: string) {
38 | // Match query to strings and Date objects / ISO UTC strings
39 | // Optionally exclude properties from being searched
40 | // If matching dates, can optionally pass in date format string
41 | if (!query || !this._objArrayCheck(array)) {
42 | return array;
43 | }
44 | const lQuery = query.toLowerCase();
45 | const isoDateRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; // ISO UTC
46 | const dateF = dateFormat ? dateFormat : 'medium';
47 | const filteredArray = array.filter(item => {
48 | for (const key in item) {
49 | if (item.hasOwnProperty(key)) {
50 | if (!excludeProps || excludeProps.indexOf(key) === -1) {
51 | const thisVal = item[key];
52 | if (
53 | // Value is a string and NOT a UTC date
54 | typeof thisVal === 'string' &&
55 | !thisVal.match(isoDateRegex) &&
56 | thisVal.toLowerCase().indexOf(lQuery) !== -1
57 | ) {
58 | return true;
59 | } else if (
60 | // Value is a Date object or UTC string
61 | (thisVal instanceof Date || thisVal.toString().match(isoDateRegex)) &&
62 | // https://angular.io/docs/ts/latest/api/common/index/DatePipe-pipe.html
63 | // Matching date format string passed in as param (or default to 'medium')
64 | this.datePipe.transform(thisVal, dateF).toLowerCase().indexOf(lQuery) !== -1
65 | ) {
66 | return true;
67 | }
68 | }
69 | }
70 | }
71 | });
72 | return filteredArray;
73 | }
74 |
75 | noSearchResults(arr: any[], query: string): boolean {
76 | // Check if array searched by query returned any results
77 | return !!(!arr.length && query);
78 | }
79 |
80 | orderByDate(array: any[], prop: string, reverse?: boolean) {
81 | // Order an array of objects by a date property
82 | // Default: ascending (1992->2017 | Jan->Dec)
83 | if (!prop || !this._objArrayCheck(array)) {
84 | return array;
85 | }
86 | const sortedArray = array.sort((a, b) => {
87 | const dateA = new Date(a[prop]).getTime();
88 | const dateB = new Date(b[prop]).getTime();
89 | return !reverse ? dateA - dateB : dateB - dateA;
90 | });
91 | return sortedArray;
92 | }
93 |
94 | }
95 |
--------------------------------------------------------------------------------
/angular.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3 | "version": 1,
4 | "newProjectRoot": "projects",
5 | "projects": {
6 | "mean-app": {
7 | "root": "",
8 | "sourceRoot": "src",
9 | "projectType": "application",
10 | "architect": {
11 | "build": {
12 | "builder": "@angular-devkit/build-angular:browser",
13 | "options": {
14 | "outputPath": "dist",
15 | "index": "src/index.html",
16 | "main": "src/main.ts",
17 | "tsConfig": "src/tsconfig.app.json",
18 | "polyfills": "src/polyfills.ts",
19 | "assets": [
20 | "src/assets",
21 | "src/favicon.ico"
22 | ],
23 | "styles": [
24 | "src/assets/scss/styles.scss"
25 | ],
26 | "scripts": []
27 | },
28 | "configurations": {
29 | "production": {
30 | "optimization": true,
31 | "outputHashing": "all",
32 | "sourceMap": false,
33 | "extractCss": true,
34 | "namedChunks": false,
35 | "aot": true,
36 | "extractLicenses": true,
37 | "vendorChunk": false,
38 | "buildOptimizer": true,
39 | "fileReplacements": [
40 | {
41 | "replace": "src/environments/environment.ts",
42 | "with": "src/environments/environment.prod.ts"
43 | }
44 | ]
45 | }
46 | }
47 | },
48 | "serve": {
49 | "builder": "@angular-devkit/build-angular:dev-server",
50 | "options": {
51 | "browserTarget": "mean-app:build"
52 | },
53 | "configurations": {
54 | "production": {
55 | "browserTarget": "mean-app:build:production"
56 | }
57 | }
58 | },
59 | "extract-i18n": {
60 | "builder": "@angular-devkit/build-angular:extract-i18n",
61 | "options": {
62 | "browserTarget": "mean-app:build"
63 | }
64 | },
65 | "test": {
66 | "builder": "@angular-devkit/build-angular:karma",
67 | "options": {
68 | "main": "src/test.ts",
69 | "karmaConfig": "./karma.conf.js",
70 | "polyfills": "src/polyfills.ts",
71 | "tsConfig": "src/tsconfig.spec.json",
72 | "scripts": [],
73 | "styles": [
74 | "src/assets/scss/styles.scss"
75 | ],
76 | "assets": [
77 | "src/assets",
78 | "src/favicon.ico"
79 | ]
80 | }
81 | },
82 | "lint": {
83 | "builder": "@angular-devkit/build-angular:tslint",
84 | "options": {
85 | "tsConfig": [
86 | "src/tsconfig.app.json",
87 | "src/tsconfig.spec.json"
88 | ],
89 | "exclude": []
90 | }
91 | }
92 | }
93 | },
94 | "mean-app-e2e": {
95 | "root": "",
96 | "sourceRoot": "",
97 | "projectType": "application",
98 | "architect": {
99 | "e2e": {
100 | "builder": "@angular-devkit/build-angular:protractor",
101 | "options": {
102 | "protractorConfig": "./protractor.conf.js",
103 | "devServerTarget": "mean-app:serve"
104 | }
105 | },
106 | "lint": {
107 | "builder": "@angular-devkit/build-angular:tslint",
108 | "options": {
109 | "tsConfig": [
110 | "e2e/tsconfig.e2e.json"
111 | ],
112 | "exclude": []
113 | }
114 | }
115 | }
116 | }
117 | },
118 | "defaultProject": "mean-app",
119 | "schematics": {
120 | "@schematics/angular:component": {
121 | "prefix": "app",
122 | "styleext": "scss"
123 | },
124 | "@schematics/angular:directive": {
125 | "prefix": "app"
126 | }
127 | }
128 | }
--------------------------------------------------------------------------------
/src/app/pages/event/rsvp/rsvp.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, Input, OnDestroy } from '@angular/core';
2 | import { expandCollapse } from './../../../core/expand-collapse.animation';
3 | import { AuthService } from './../../../auth/auth.service';
4 | import { ApiService } from './../../../core/api.service';
5 | import { UtilsService } from './../../../core/utils.service';
6 | import { FilterSortService } from './../../../core/filter-sort.service';
7 | import { RsvpModel } from './../../../core/models/rsvp.model';
8 | import { Subscription } from 'rxjs';
9 |
10 | @Component({
11 | selector: 'app-rsvp',
12 | animations: [expandCollapse],
13 | templateUrl: './rsvp.component.html',
14 | styleUrls: ['./rsvp.component.scss']
15 | })
16 | export class RsvpComponent implements OnInit, OnDestroy {
17 | @Input() eventId: string;
18 | @Input() eventPast: boolean;
19 | rsvpsSub: Subscription;
20 | rsvps: RsvpModel[];
21 | loading: boolean;
22 | error: boolean;
23 | userRsvp: RsvpModel;
24 | totalAttending: number;
25 | footerTense: string;
26 | showEditForm: boolean;
27 | editBtnText: string;
28 | showAllRsvps = false;
29 | showRsvpsText = 'View All RSVPs';
30 |
31 | constructor(
32 | public auth: AuthService,
33 | private api: ApiService,
34 | public utils: UtilsService,
35 | public fs: FilterSortService
36 | ) { }
37 |
38 | ngOnInit() {
39 | this.footerTense = !this.eventPast ? 'plan to attend this event.' : 'attended this event.';
40 | this._getRSVPs();
41 | this.toggleEditForm(false);
42 | }
43 |
44 | private _getRSVPs() {
45 | this.loading = true;
46 | // Get RSVPs by event ID
47 | this.rsvpsSub = this.api
48 | .getRsvpsByEventId$(this.eventId)
49 | .subscribe(
50 | res => {
51 | this.rsvps = res;
52 | this._updateRsvpState();
53 | this.loading = false;
54 | },
55 | err => {
56 | console.error(err);
57 | this.loading = false;
58 | this.error = true;
59 | }
60 | );
61 | }
62 |
63 | toggleEditForm(setVal?: boolean) {
64 | this.showEditForm = setVal !== undefined ? setVal : !this.showEditForm;
65 | this.editBtnText = this.showEditForm ? 'Cancel Edit' : 'Edit My RSVP';
66 | }
67 |
68 | toggleShowRsvps() {
69 | this.showAllRsvps = !this.showAllRsvps;
70 | this.showRsvpsText = this.showAllRsvps ? 'Hide RSVPs' : 'Show All RSVPs';
71 | }
72 |
73 | onSubmitRsvp(e) {
74 | if (e.rsvp) {
75 | this.userRsvp = e.rsvp;
76 | this._updateRsvpState(true);
77 | this.toggleEditForm(false);
78 | }
79 | }
80 |
81 | private _updateRsvpState(changed?: boolean) {
82 | // If RSVP matching user ID is already
83 | // in RSVP array, set as initial RSVP
84 | const _initialUserRsvp = this.rsvps.filter(rsvp => {
85 | return rsvp.userId === this.auth.userProfile.sub;
86 | })[0];
87 |
88 | // If user has not RSVPed before and has made
89 | // a change, push new RSVP to local RSVPs store
90 | if (!_initialUserRsvp && this.userRsvp && changed) {
91 | this.rsvps.push(this.userRsvp);
92 | }
93 | this._setUserRsvpGetAttending(changed);
94 | }
95 |
96 | private _setUserRsvpGetAttending(changed?: boolean) {
97 | // Iterate over RSVPs to get/set user's RSVP
98 | // and get total number of attending guests
99 | let guests = 0;
100 | const rsvpArr = this.rsvps.map(rsvp => {
101 | // If user has an existing RSVP
102 | if (rsvp.userId === this.auth.userProfile.sub) {
103 | if (changed) {
104 | // If user edited their RSVP, set with updated data
105 | rsvp = this.userRsvp;
106 | } else {
107 | // If no changes were made, set userRsvp property
108 | // (This applies on ngOnInit)
109 | this.userRsvp = rsvp;
110 | }
111 | }
112 | // Count total number of attendees
113 | // + additional guests
114 | if (rsvp.attending) {
115 | guests++;
116 | if (rsvp.guests) {
117 | guests += rsvp.guests;
118 | }
119 | }
120 | return rsvp;
121 | });
122 | this.rsvps = rsvpArr;
123 | this.totalAttending = guests;
124 | }
125 |
126 | ngOnDestroy() {
127 | this.rsvpsSub.unsubscribe();
128 | }
129 |
130 | }
131 |
--------------------------------------------------------------------------------
/src/app/pages/event/rsvp/rsvp.component.html:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
You cannot RSVP to an event that has already ended.
10 |
11 |
12 |
13 |
14 |
15 |
16 |
You responded to this event with the following information:
17 |
18 |
19 |
20 | -
21 | Name:{{ userRsvp.name }}
22 |
23 | -
24 | Attending:{{ utils.booleanToText(userRsvp.attending) }}
25 |
26 | -
27 | Additional Guests:{{ userRsvp.guests }}
28 |
29 | -
30 | Comments:
31 |
32 |
33 |
34 |
35 |
39 |
40 |
45 |
46 |
47 |
48 |
49 |
50 |
Fill out the form below to respond:
51 |
52 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
All RSVPs
66 |
There are currently no RSVPs for this event.
67 |
68 |
69 |
70 | -
71 | Attending
72 | {{ totalAttending }}
73 |
74 | -
77 | {{ rsvp.name }} {{ utils.showPlusOnes(rsvp.guests) }}
78 |
79 |
80 |
81 |
82 | -
83 | Not Attending
84 | {{ fs.filter(rsvps, 'attending', false).length }}
85 |
86 | -
89 | {{ rsvp.name }}
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | Oops! There was an error retrieving RSVPs for this event.
101 |
102 |
103 |
104 |
105 |
106 |
109 |
--------------------------------------------------------------------------------
/src/app/core/api.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
3 | import { AuthService } from './../auth/auth.service';
4 | import { throwError as ObservableThrowError, Observable } from 'rxjs';
5 | import { catchError } from 'rxjs/operators';
6 | import { ENV } from './env.config';
7 | import { EventModel } from './models/event.model';
8 | import { RsvpModel } from './models/rsvp.model';
9 |
10 | @Injectable()
11 | export class ApiService {
12 | constructor(
13 | private http: HttpClient,
14 | private auth: AuthService
15 | ) { }
16 |
17 | private get _authHeader(): string {
18 | return `Bearer ${this.auth.accessToken}`;
19 | }
20 |
21 | // GET list of public, future events
22 | getEvents$(): Observable {
23 | return this.http
24 | .get(`${ENV.BASE_API}events`)
25 | .pipe(
26 | catchError((error) => this._handleError(error))
27 | );
28 | }
29 |
30 | // GET all events - private and public (admin only)
31 | getAdminEvents$(): Observable {
32 | return this.http
33 | .get(`${ENV.BASE_API}events/admin`, {
34 | headers: new HttpHeaders().set('Authorization', this._authHeader)
35 | })
36 | .pipe(
37 | catchError((error) => this._handleError(error))
38 | );
39 | }
40 |
41 | // GET an event by ID (login required)
42 | getEventById$(id: string): Observable {
43 | return this.http
44 | .get(`${ENV.BASE_API}event/${id}`, {
45 | headers: new HttpHeaders().set('Authorization', this._authHeader)
46 | })
47 | .pipe(
48 | catchError((error) => this._handleError(error))
49 | );
50 | }
51 |
52 | // GET RSVPs by event ID (login required)
53 | getRsvpsByEventId$(eventId: string): Observable {
54 | return this.http
55 | .get(`${ENV.BASE_API}event/${eventId}/rsvps`, {
56 | headers: new HttpHeaders().set('Authorization', this._authHeader)
57 | })
58 | .pipe(
59 | catchError((error) => this._handleError(error))
60 | );
61 | }
62 |
63 | // POST new event (admin only)
64 | postEvent$(event: EventModel): Observable {
65 | return this.http
66 | .post(`${ENV.BASE_API}event/new`, event, {
67 | headers: new HttpHeaders().set('Authorization', this._authHeader)
68 | })
69 | .pipe(
70 | catchError((error) => this._handleError(error))
71 | );
72 | }
73 |
74 | // PUT existing event (admin only)
75 | editEvent$(id: string, event: EventModel): Observable {
76 | return this.http
77 | .put(`${ENV.BASE_API}event/${id}`, event, {
78 | headers: new HttpHeaders().set('Authorization', this._authHeader)
79 | })
80 | .pipe(
81 | catchError((error) => this._handleError(error))
82 | );
83 | }
84 |
85 | // DELETE existing event and all associated RSVPs (admin only)
86 | deleteEvent$(id: string): Observable {
87 | return this.http
88 | .delete(`${ENV.BASE_API}event/${id}`, {
89 | headers: new HttpHeaders().set('Authorization', this._authHeader)
90 | })
91 | .pipe(
92 | catchError((error) => this._handleError(error))
93 | );
94 | }
95 |
96 | // GET all events a specific user has RSVPed to (login required)
97 | getUserEvents$(userId: string): Observable {
98 | return this.http
99 | .get(`${ENV.BASE_API}events/${userId}`, {
100 | headers: new HttpHeaders().set('Authorization', this._authHeader)
101 | })
102 | .pipe(
103 | catchError((error) => this._handleError(error))
104 | );
105 | }
106 |
107 | // POST new RSVP (login required)
108 | postRsvp$(rsvp: RsvpModel): Observable {
109 | return this.http
110 | .post(`${ENV.BASE_API}rsvp/new`, rsvp, {
111 | headers: new HttpHeaders().set('Authorization', this._authHeader)
112 | })
113 | .pipe(
114 | catchError((error) => this._handleError(error))
115 | );
116 | }
117 |
118 | // PUT existing RSVP (login required)
119 | editRsvp$(id: string, rsvp: RsvpModel): Observable {
120 | return this.http
121 | .put(`${ENV.BASE_API}rsvp/${id}`, rsvp, {
122 | headers: new HttpHeaders().set('Authorization', this._authHeader)
123 | })
124 | .pipe(
125 | catchError((error) => this._handleError(error))
126 | );
127 | }
128 |
129 | private _handleError(err: HttpErrorResponse | any): Observable {
130 | const errorMsg = err.message || 'Error: Unable to complete request.';
131 | if (err.message && err.message.indexOf('No JWT present') > -1) {
132 | this.auth.login();
133 | }
134 | return ObservableThrowError(errorMsg);
135 | }
136 |
137 | }
138 |
--------------------------------------------------------------------------------
/src/app/pages/admin/event-form/event-form.component.html:
--------------------------------------------------------------------------------
1 |
181 |
--------------------------------------------------------------------------------
/src/app/auth/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { Router } from '@angular/router';
3 | import { BehaviorSubject, Subscription, of, timer } from 'rxjs';
4 | import { mergeMap } from 'rxjs/operators';
5 | import { AUTH_CONFIG } from './auth.config';
6 | import * as auth0 from 'auth0-js';
7 | import { ENV } from './../core/env.config';
8 |
9 | @Injectable()
10 | export class AuthService {
11 | // Create Auth0 web auth instance
12 | private _auth0 = new auth0.WebAuth({
13 | clientID: AUTH_CONFIG.CLIENT_ID,
14 | domain: AUTH_CONFIG.CLIENT_DOMAIN,
15 | responseType: 'token',
16 | redirectUri: AUTH_CONFIG.REDIRECT,
17 | audience: AUTH_CONFIG.AUDIENCE,
18 | scope: AUTH_CONFIG.SCOPE
19 | });
20 | accessToken: string;
21 | userProfile: any;
22 | expiresAt: number;
23 | isAdmin: boolean;
24 | // Create a stream of logged in status to communicate throughout app
25 | loggedIn: boolean;
26 | loggedIn$ = new BehaviorSubject(this.loggedIn);
27 | loggingIn: boolean;
28 | // Subscribe to token expiration stream
29 | refreshSub: Subscription;
30 | routeSub: Subscription;
31 |
32 | constructor(private router: Router) {
33 | // If app auth token is not expired, request new token
34 | if (JSON.parse(localStorage.getItem('expires_at')) > Date.now()) {
35 | this.renewToken();
36 | }
37 | }
38 |
39 | setLoggedIn(value: boolean) {
40 | // Update login status behavior subject
41 | this.loggedIn$.next(value);
42 | this.loggedIn = value;
43 | }
44 |
45 | login() {
46 | // Auth0 authorize request
47 | this._auth0.authorize();
48 | }
49 |
50 | handleAuth() {
51 | // When Auth0 hash parsed, get profile
52 | this._auth0.parseHash((err, authResult) => {
53 | if (authResult && authResult.accessToken) {
54 | window.location.hash = '';
55 | this._getProfile(authResult);
56 | } else if (err) {
57 | this._clearRedirect();
58 | this.router.navigate(['/']);
59 | console.error(`Error authenticating: ${err.error}`);
60 | }
61 | });
62 | }
63 |
64 | private _getProfile(authResult) {
65 | this.loggingIn = true;
66 | // Use access token to retrieve user's profile and set session
67 | this._auth0.client.userInfo(authResult.accessToken, (err, profile) => {
68 | if (profile) {
69 | this._setSession(authResult, profile);
70 | this._redirect();
71 | } else if (err) {
72 | console.warn(`Error retrieving profile: ${err.error}`);
73 | }
74 | });
75 | }
76 |
77 | private _setSession(authResult, profile?) {
78 | this.expiresAt = (authResult.expiresIn * 1000) + Date.now();
79 | // Store expiration in local storage to access in constructor
80 | localStorage.setItem('expires_at', JSON.stringify(this.expiresAt));
81 | this.accessToken = authResult.accessToken;
82 | // If initial login, set profile and admin information
83 | if (profile) {
84 | this.userProfile = profile;
85 | this.isAdmin = this._checkAdmin(profile);
86 | }
87 | // Update login status in loggedIn$ stream
88 | this.setLoggedIn(true);
89 | this.loggingIn = false;
90 | // Schedule access token renewal
91 | this.scheduleRenewal();
92 | }
93 |
94 | private _checkAdmin(profile) {
95 | // Check if the user has admin role
96 | const roles = profile[AUTH_CONFIG.NAMESPACE] || [];
97 | return roles.indexOf('admin') > -1;
98 | }
99 |
100 | private _redirect() {
101 | // Redirect with or without 'tab' query parameter
102 | // Note: does not support additional params besides 'tab'
103 | const fullRedirect = decodeURI(localStorage.getItem('authRedirect'));
104 | const redirectArr = fullRedirect.split('?tab=');
105 | const navArr = [redirectArr[0] || '/'];
106 | const tabObj = redirectArr[1] ? { queryParams: { tab: redirectArr[1] }} : null;
107 |
108 | if (!tabObj) {
109 | this.router.navigate(navArr);
110 | } else {
111 | this.router.navigate(navArr, tabObj);
112 | }
113 | // Redirection completed; clear redirect from storage
114 | this._clearRedirect();
115 | }
116 |
117 | private _clearRedirect() {
118 | // Remove redirect from localStorage
119 | localStorage.removeItem('authRedirect');
120 | }
121 |
122 | private _clearExpiration() {
123 | // Remove token expiration from localStorage
124 | localStorage.removeItem('expires_at');
125 | }
126 |
127 | logout() {
128 | // Remove data from localStorage
129 | this._clearExpiration();
130 | this._clearRedirect();
131 | // End Auth0 authentication session
132 | this._auth0.logout({
133 | clientId: AUTH_CONFIG.CLIENT_ID,
134 | returnTo: ENV.BASE_URI
135 | });
136 | }
137 |
138 | get tokenValid(): boolean {
139 | // Check if current time is past access token's expiration
140 | return Date.now() < JSON.parse(localStorage.getItem('expires_at'));
141 | }
142 |
143 | renewToken() {
144 | // Check for valid Auth0 session
145 | this._auth0.checkSession({}, (err, authResult) => {
146 | if (authResult && authResult.accessToken) {
147 | this._getProfile(authResult);
148 | } else {
149 | this._clearExpiration();
150 | }
151 | });
152 | }
153 |
154 | scheduleRenewal() {
155 | // If last token is expired, do nothing
156 | if (!this.tokenValid) { return; }
157 | // Unsubscribe from previous expiration observable
158 | this.unscheduleRenewal();
159 | // Create and subscribe to expiration observable
160 | const expiresIn$ = of(this.expiresAt).pipe(
161 | mergeMap(
162 | expires => {
163 | const now = Date.now();
164 | // Use timer to track delay until expiration
165 | // to run the refresh at the proper time
166 | return timer(Math.max(1, expires - now));
167 | }
168 | )
169 | );
170 |
171 | this.refreshSub = expiresIn$
172 | .subscribe(
173 | () => {
174 | this.renewToken();
175 | this.scheduleRenewal();
176 | }
177 | );
178 | }
179 |
180 | unscheduleRenewal() {
181 | if (this.refreshSub) {
182 | this.refreshSub.unsubscribe();
183 | }
184 | }
185 |
186 | }
187 |
--------------------------------------------------------------------------------
/server/api.js:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------
3 | | Dependencies
4 | |--------------------------------------
5 | */
6 |
7 | const jwt = require('express-jwt');
8 | const jwks = require('jwks-rsa');
9 | const Event = require('./models/Event');
10 | const Rsvp = require('./models/Rsvp');
11 |
12 | /*
13 | |--------------------------------------
14 | | Authentication Middleware
15 | |--------------------------------------
16 | */
17 |
18 | module.exports = function(app, config) {
19 | // Authentication middleware
20 | const jwtCheck = jwt({
21 | secret: jwks.expressJwtSecret({
22 | cache: true,
23 | rateLimit: true,
24 | jwksRequestsPerMinute: 5,
25 | jwksUri: `https://${config.AUTH0_DOMAIN}/.well-known/jwks.json`
26 | }),
27 | audience: config.AUTH0_API_AUDIENCE,
28 | issuer: `https://${config.AUTH0_DOMAIN}/`,
29 | algorithm: 'RS256'
30 | });
31 |
32 | // Check for an authenticated admin user
33 | const adminCheck = (req, res, next) => {
34 | const roles = req.user[config.NAMESPACE] || [];
35 | if (roles.indexOf('admin') > -1) {
36 | next();
37 | } else {
38 | res.status(401).send({message: 'Not authorized for admin access'});
39 | }
40 | }
41 |
42 | /*
43 | |--------------------------------------
44 | | API Routes
45 | |--------------------------------------
46 | */
47 |
48 | const _eventListProjection = 'title startDatetime endDatetime viewPublic';
49 |
50 | // GET API root
51 | app.get('/api/', (req, res) => {
52 | res.send('API works');
53 | });
54 |
55 | // GET list of public events starting in the future
56 | app.get('/api/events', (req, res) => {
57 | Event.find({viewPublic: true, startDatetime: { $gte: new Date() }},
58 | _eventListProjection, (err, events) => {
59 | let eventsArr = [];
60 | if (err) {
61 | return res.status(500).send({message: err.message});
62 | }
63 | if (events) {
64 | events.forEach(event => {
65 | eventsArr.push(event);
66 | });
67 | }
68 | res.send(eventsArr);
69 | }
70 | );
71 | });
72 |
73 | // GET list of all events, public and private (admin only)
74 | app.get('/api/events/admin', jwtCheck, adminCheck, (req, res) => {
75 | Event.find({}, _eventListProjection, (err, events) => {
76 | let eventsArr = [];
77 | if (err) {
78 | return res.status(500).send({message: err.message});
79 | }
80 | if (events) {
81 | events.forEach(event => {
82 | eventsArr.push(event);
83 | });
84 | }
85 | res.send(eventsArr);
86 | }
87 | );
88 | });
89 |
90 | // GET event by event ID
91 | app.get('/api/event/:id', jwtCheck, (req, res) => {
92 | Event.findById(req.params.id, (err, event) => {
93 | if (err) {
94 | return res.status(500).send({message: err.message});
95 | }
96 | if (!event) {
97 | return res.status(400).send({message: 'Event not found.'});
98 | }
99 | res.send(event);
100 | });
101 | });
102 |
103 | // GET RSVPs by event ID
104 | app.get('/api/event/:eventId/rsvps', jwtCheck, (req, res) => {
105 | Rsvp.find({eventId: req.params.eventId}, (err, rsvps) => {
106 | let rsvpsArr = [];
107 | if (err) {
108 | return res.status(500).send({message: err.message});
109 | }
110 | if (rsvps) {
111 | rsvps.forEach(rsvp => {
112 | rsvpsArr.push(rsvp);
113 | });
114 | }
115 | res.send(rsvpsArr);
116 | });
117 | });
118 |
119 | // GET list of upcoming events user has RSVPed to
120 | app.get('/api/events/:userId', jwtCheck, (req, res) => {
121 | Rsvp.find({userId: req.params.userId}, 'eventId', (err, rsvps) => {
122 | const _eventIdsArr = rsvps.map(rsvp => rsvp.eventId);
123 | const _rsvpEventsProjection = 'title startDatetime endDatetime';
124 | let eventsArr = [];
125 |
126 | if (err) {
127 | return res.status(500).send({message: err.message});
128 | }
129 | if (rsvps) {
130 | Event.find(
131 | {_id: {$in: _eventIdsArr}, startDatetime: { $gte: new Date() }},
132 | _rsvpEventsProjection, (err, events) => {
133 | if (err) {
134 | return res.status(500).send({message: err.message});
135 | }
136 | if (events) {
137 | events.forEach(event => {
138 | eventsArr.push(event);
139 | });
140 | }
141 | res.send(eventsArr);
142 | });
143 | }
144 | });
145 | });
146 |
147 | // POST a new event
148 | app.post('/api/event/new', jwtCheck, adminCheck, (req, res) => {
149 | Event.findOne({
150 | title: req.body.title,
151 | location: req.body.location,
152 | startDatetime: req.body.startDatetime}, (err, existingEvent) => {
153 | if (err) {
154 | return res.status(500).send({message: err.message});
155 | }
156 | if (existingEvent) {
157 | return res.status(409).send({message: 'You have already created an event with this title, location, and start date/time.'});
158 | }
159 | const event = new Event({
160 | title: req.body.title,
161 | location: req.body.location,
162 | startDatetime: req.body.startDatetime,
163 | endDatetime: req.body.endDatetime,
164 | description: req.body.description,
165 | viewPublic: req.body.viewPublic
166 | });
167 | event.save((err) => {
168 | if (err) {
169 | return res.status(500).send({message: err.message});
170 | }
171 | res.send(event);
172 | });
173 | });
174 | });
175 |
176 | // PUT (edit) an existing event
177 | app.put('/api/event/:id', jwtCheck, adminCheck, (req, res) => {
178 | Event.findById(req.params.id, (err, event) => {
179 | if (err) {
180 | return res.status(500).send({message: err.message});
181 | }
182 | if (!event) {
183 | return res.status(400).send({message: 'Event not found.'});
184 | }
185 | event.title = req.body.title;
186 | event.location = req.body.location;
187 | event.startDatetime = req.body.startDatetime;
188 | event.endDatetime = req.body.endDatetime;
189 | event.viewPublic = req.body.viewPublic;
190 | event.description = req.body.description;
191 |
192 | event.save(err => {
193 | if (err) {
194 | return res.status(500).send({message: err.message});
195 | }
196 | res.send(event);
197 | });
198 | });
199 | });
200 |
201 | // DELETE an event and all associated RSVPs
202 | app.delete('/api/event/:id', jwtCheck, adminCheck, (req, res) => {
203 | Event.findById(req.params.id, (err, event) => {
204 | if (err) {
205 | return res.status(500).send({message: err.message});
206 | }
207 | if (!event) {
208 | return res.status(400).send({message: 'Event not found.'});
209 | }
210 | Rsvp.find({eventId: req.params.id}, (err, rsvps) => {
211 | if (rsvps) {
212 | rsvps.forEach(rsvp => {
213 | rsvp.remove();
214 | });
215 | }
216 | event.remove(err => {
217 | if (err) {
218 | return res.status(500).send({message: err.message});
219 | }
220 | res.status(200).send({message: 'Event and RSVPs successfully deleted.'});
221 | });
222 | });
223 | });
224 | });
225 |
226 | // POST a new RSVP
227 | app.post('/api/rsvp/new', jwtCheck, (req, res) => {
228 | Rsvp.findOne({eventId: req.body.eventId, userId: req.body.userId}, (err, existingRsvp) => {
229 | if (err) {
230 | return res.status(500).send({message: err.message});
231 | }
232 | if (existingRsvp) {
233 | return res.status(409).send({message: 'You have already RSVPed to this event.'});
234 | }
235 | const rsvp = new Rsvp({
236 | userId: req.body.userId,
237 | name: req.body.name,
238 | eventId: req.body.eventId,
239 | attending: req.body.attending,
240 | guests: req.body.guests,
241 | comments: req.body.comments
242 | });
243 | rsvp.save((err) => {
244 | if (err) {
245 | return res.status(500).send({message: err.message});
246 | }
247 | res.send(rsvp);
248 | });
249 | });
250 | });
251 |
252 | // PUT (edit) an existing RSVP
253 | app.put('/api/rsvp/:id', jwtCheck, (req, res) => {
254 | Rsvp.findById(req.params.id, (err, rsvp) => {
255 | if (err) {
256 | return res.status(500).send({message: err.message});
257 | }
258 | if (!rsvp) {
259 | return res.status(400).send({message: 'RSVP not found.'});
260 | }
261 | if (rsvp.userId !== req.user.sub) {
262 | return res.status(401).send({message: 'You cannot edit someone else\'s RSVP.'});
263 | }
264 | rsvp.name = req.body.name;
265 | rsvp.attending = req.body.attending;
266 | rsvp.guests = req.body.guests;
267 | rsvp.comments = req.body.comments;
268 |
269 | rsvp.save(err => {
270 | if (err) {
271 | return res.status(500).send({message: err.message});
272 | }
273 | res.send(rsvp);
274 | });
275 | });
276 | });
277 |
278 | };
279 |
--------------------------------------------------------------------------------
/src/app/pages/admin/event-form/event-form.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit, OnDestroy, Input } from '@angular/core';
2 | import { FormGroup, FormBuilder, Validators, AbstractControl } from '@angular/forms';
3 | import { Router } from '@angular/router';
4 | import { Subscription } from 'rxjs';
5 | import { ApiService } from './../../../core/api.service';
6 | import { EventModel, FormEventModel } from './../../../core/models/event.model';
7 | import { DatePipe } from '@angular/common';
8 | import { dateValidator } from './../../../core/forms/date.validator';
9 | import { dateRangeValidator } from './../../../core/forms/date-range.validator';
10 | import { DATE_REGEX, TIME_REGEX, stringsToDate } from './../../../core/forms/formUtils.factory';
11 | import { EventFormService } from './event-form.service';
12 |
13 | @Component({
14 | selector: 'app-event-form',
15 | templateUrl: './event-form.component.html',
16 | styleUrls: ['./event-form.component.scss'],
17 | providers: [ EventFormService ]
18 | })
19 | export class EventFormComponent implements OnInit, OnDestroy {
20 | @Input() event: EventModel;
21 | isEdit: boolean;
22 | // FormBuilder form
23 | eventForm: FormGroup;
24 | datesGroup: AbstractControl;
25 | // Model storing initial form values
26 | formEvent: FormEventModel;
27 | // Form validation and disabled logic
28 | formErrors: any;
29 | formChangeSub: Subscription;
30 | // Form submission
31 | submitEventObj: EventModel;
32 | submitEventSub: Subscription;
33 | error: boolean;
34 | submitting: boolean;
35 | submitBtnText: string;
36 |
37 | constructor(
38 | private fb: FormBuilder,
39 | private api: ApiService,
40 | private datePipe: DatePipe,
41 | public ef: EventFormService,
42 | private router: Router
43 | ) { }
44 |
45 | ngOnInit() {
46 | this.formErrors = this.ef.formErrors;
47 | this.isEdit = !!this.event;
48 | this.submitBtnText = this.isEdit ? 'Update Event' : 'Create Event';
49 | // Set initial form data
50 | this.formEvent = this._setFormEvent();
51 | // Use FormBuilder to construct the form
52 | this._buildForm();
53 | }
54 |
55 | private _setFormEvent() {
56 | if (!this.isEdit) {
57 | // If creating a new event, create new
58 | // FormEventModel with default null data
59 | return new FormEventModel(null, null, null, null, null, null, null);
60 | } else {
61 | // If editing existing event, create new
62 | // FormEventModel from existing data
63 | // Transform datetimes:
64 | // https://angular.io/api/common/DatePipe
65 | // _shortDate: 1/7/2017
66 | // 'shortTime': 12:05 PM
67 | const _shortDate = 'M/d/yyyy';
68 | return new FormEventModel(
69 | this.event.title,
70 | this.event.location,
71 | this.datePipe.transform(this.event.startDatetime, _shortDate),
72 | this.datePipe.transform(this.event.startDatetime, 'shortTime'),
73 | this.datePipe.transform(this.event.endDatetime, _shortDate),
74 | this.datePipe.transform(this.event.endDatetime, 'shortTime'),
75 | this.event.viewPublic,
76 | this.event.description
77 | );
78 | }
79 | }
80 |
81 | private _buildForm() {
82 | this.eventForm = this.fb.group({
83 | title: [this.formEvent.title, [
84 | Validators.required,
85 | Validators.minLength(this.ef.textMin),
86 | Validators.maxLength(this.ef.titleMax)
87 | ]],
88 | location: [this.formEvent.location, [
89 | Validators.required,
90 | Validators.minLength(this.ef.textMin),
91 | Validators.maxLength(this.ef.locMax)
92 | ]],
93 | viewPublic: [this.formEvent.viewPublic,
94 | Validators.required
95 | ],
96 | description: [this.formEvent.description,
97 | Validators.maxLength(this.ef.descMax)
98 | ],
99 | datesGroup: this.fb.group({
100 | startDate: [this.formEvent.startDate, [
101 | Validators.required,
102 | Validators.maxLength(this.ef.dateMax),
103 | Validators.pattern(DATE_REGEX),
104 | dateValidator()
105 | ]],
106 | startTime: [this.formEvent.startTime, [
107 | Validators.required,
108 | Validators.maxLength(this.ef.timeMax),
109 | Validators.pattern(TIME_REGEX)
110 | ]],
111 | endDate: [this.formEvent.endDate, [
112 | Validators.required,
113 | Validators.maxLength(this.ef.dateMax),
114 | Validators.pattern(DATE_REGEX),
115 | dateValidator()
116 | ]],
117 | endTime: [this.formEvent.endTime, [
118 | Validators.required,
119 | Validators.maxLength(this.ef.timeMax),
120 | Validators.pattern(TIME_REGEX)
121 | ]]
122 | }, { validator: dateRangeValidator })
123 | });
124 | // Set local property to eventForm datesGroup control
125 | this.datesGroup = this.eventForm.get('datesGroup');
126 |
127 | // Subscribe to form value changes
128 | this.formChangeSub = this.eventForm
129 | .valueChanges
130 | .subscribe(data => this._onValueChanged());
131 |
132 | // If edit: mark fields dirty to trigger immediate
133 | // validation in case editing an event that is no
134 | // longer valid (for example, an event in the past)
135 | if (this.isEdit) {
136 | const _markDirty = group => {
137 | for (const i in group.controls) {
138 | if (group.controls.hasOwnProperty(i)) {
139 | group.controls[i].markAsDirty();
140 | }
141 | }
142 | };
143 | _markDirty(this.eventForm);
144 | _markDirty(this.datesGroup);
145 | }
146 |
147 | this._onValueChanged();
148 | }
149 |
150 | private _onValueChanged() {
151 | if (!this.eventForm) { return; }
152 | const _setErrMsgs = (control: AbstractControl, errorsObj: any, field: string) => {
153 | if (control && control.dirty && control.invalid) {
154 | const messages = this.ef.validationMessages[field];
155 | for (const key in control.errors) {
156 | if (control.errors.hasOwnProperty(key)) {
157 | errorsObj[field] += messages[key] + '
';
158 | }
159 | }
160 | }
161 | };
162 |
163 | // Check validation and set errors
164 | for (const field in this.formErrors) {
165 | if (this.formErrors.hasOwnProperty(field)) {
166 | if (field !== 'datesGroup') {
167 | // Set errors for fields not inside datesGroup
168 | // Clear previous error message (if any)
169 | this.formErrors[field] = '';
170 | _setErrMsgs(this.eventForm.get(field), this.formErrors, field);
171 | } else {
172 | // Set errors for fields inside datesGroup
173 | const datesGroupErrors = this.formErrors['datesGroup'];
174 | for (const dateField in datesGroupErrors) {
175 | if (datesGroupErrors.hasOwnProperty(dateField)) {
176 | // Clear previous error message (if any)
177 | datesGroupErrors[dateField] = '';
178 | _setErrMsgs(this.datesGroup.get(dateField), datesGroupErrors, dateField);
179 | }
180 | }
181 | }
182 | }
183 | }
184 | }
185 |
186 | private _getSubmitObj() {
187 | const startDate = this.datesGroup.get('startDate').value;
188 | const startTime = this.datesGroup.get('startTime').value;
189 | const endDate = this.datesGroup.get('endDate').value;
190 | const endTime = this.datesGroup.get('endTime').value;
191 | // Convert form startDate/startTime and endDate/endTime
192 | // to JS dates and populate a new EventModel for submission
193 | return new EventModel(
194 | this.eventForm.get('title').value,
195 | this.eventForm.get('location').value,
196 | stringsToDate(startDate, startTime),
197 | stringsToDate(endDate, endTime),
198 | this.eventForm.get('viewPublic').value,
199 | this.eventForm.get('description').value,
200 | this.event ? this.event._id : null
201 | );
202 | }
203 |
204 | onSubmit() {
205 | this.submitting = true;
206 | this.submitEventObj = this._getSubmitObj();
207 |
208 | if (!this.isEdit) {
209 | this.submitEventSub = this.api
210 | .postEvent$(this.submitEventObj)
211 | .subscribe(
212 | data => this._handleSubmitSuccess(data),
213 | err => this._handleSubmitError(err)
214 | );
215 | } else {
216 | this.submitEventSub = this.api
217 | .editEvent$(this.event._id, this.submitEventObj)
218 | .subscribe(
219 | data => this._handleSubmitSuccess(data),
220 | err => this._handleSubmitError(err)
221 | );
222 | }
223 | }
224 |
225 | private _handleSubmitSuccess(res) {
226 | this.error = false;
227 | this.submitting = false;
228 | // Redirect to event detail
229 | this.router.navigate(['/event', res._id]);
230 | }
231 |
232 | private _handleSubmitError(err) {
233 | console.error(err);
234 | this.submitting = false;
235 | this.error = true;
236 | }
237 |
238 | resetForm() {
239 | this.eventForm.reset();
240 | }
241 |
242 | ngOnDestroy() {
243 | if (this.submitEventSub) {
244 | this.submitEventSub.unsubscribe();
245 | }
246 | this.formChangeSub.unsubscribe();
247 | }
248 |
249 | }
250 |
--------------------------------------------------------------------------------