Skip to content

Commit 8db2505

Browse files
McFrappeFrewacom
andauthored
Added push notifications and subscriptions (#103)
* Added subscriptions and expo push tokens * Changed field type of uuid on subscription model to string * Added test push token in seeder * Added push notification ticket error handling * began with notifications * notification model is done, created migration, added beforeCreate/beforeDelete for notification in events model * added tests for subsrcriptions and started making tests for notifications, though hthey need a controller and routes (endpoints) * can now fetch existing notification in the system by knowing their Id's * added tests, passing * added serialization for the updated_at and created_at field * added filtering to date for notifications * fixed CI tests, moved 'hooks' outside from the events model... sad. It was pretty. The creation and deletion of notification is now done in EventsController * added tests * refactored notification check into an if statement * moved to seperate file, is now exported * added tests for datetime format checking etc * added nation.oid to notification model * Added pg-boss scheduler * Send push notifications on notification model create * Added receipt fetching to Expo service * Formatted code * Fixed subscription callback execution * Fixed broken tests * Skipped tests in CI using skipInCi * Cleaned up push ticket error validation * Added notification filtering based on token * Updated insomnia workspace * Throw error if query push token is invalid * Added test for making sure only matching notifications are returned * Formatted code * moved function so utils file' * Updated subscription tests * Skipped removal of notification when deleting events Co-authored-by: Fredrik Engstrand <[email protected]> Co-authored-by: Fredrik Engstrand <[email protected]>
1 parent e19d362 commit 8db2505

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+7964
-5929
lines changed

.adonisrc.json

+8-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@
1313
"Database": "database",
1414
"Contracts": "contracts"
1515
},
16-
"preloads": ["./start/routes", "./start/kernel"],
16+
"preloads": [
17+
"./start/routes",
18+
"./start/kernel",
19+
{
20+
"file": "./start/rules",
21+
"environment": ["web"]
22+
}
23+
],
1724
"providers": ["./providers/AppProvider", "@adonisjs/core", "@adonisjs/lucid", "@adonisjs/auth"],
1825
"aceProviders": ["@adonisjs/repl"],
1926
"metaFiles": [

.env

+1
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ HOST=0.0.0.0
33
NODE_ENV=development
44
APP_KEY=yklM2_A0w5HdcDn22kwHWJS1Mcu4Exra
55
DB_CONNECTION=pg
6+
EXPO_ACCESS_TOKEN=q1nBkAG6kGmV3cD-OYrlCRFEHFAXAstVUGRwjUSD

.insomnia/Workspace/nationskollen.json

+1-1
Large diffs are not rendered by default.

app/Controllers/Http/EventsController.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,22 @@
55
* @category Controller
66
* @module EventsController
77
*/
8+
import {
9+
getNation,
10+
getEvent,
11+
getValidatedData,
12+
} from 'App/Utils/Request'
813
import { DateTime } from 'luxon'
914
import Event from 'App/Models/Event'
1015
import { getPageNumber } from 'App/Utils/Paginate'
1116
import { ExtractScopes } from '@ioc:Adonis/Lucid/Model'
1217
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
1318
import { attemptFileUpload, attemptFileRemoval } from 'App/Utils/Upload'
14-
import { getNation, getEvent, getValidatedData } from 'App/Utils/Request'
19+
import PaginationValidator from 'App/Validators/PaginationValidator'
1520
import EventUpdateValidator from 'App/Validators/Events/UpdateValidator'
1621
import EventCreateValidator from 'App/Validators/Events/CreateValidator'
1722
import EventUploadValidator from 'App/Validators/Events/UploadValidator'
1823
import EventFilterValidator from 'App/Validators/Events/FilterValidator'
19-
import PaginationValidator from 'App/Validators/PaginationValidator'
2024

2125
/**
2226
* Event controller
@@ -26,7 +30,7 @@ export default class EventsController {
2630
* Method that applies given filters depedning on what type of event to
2731
* filter after
2832
* @param scopes - The different scopes that exists in the system
29-
* @param filters - The filder to apply
33+
* @param filters - The filter to apply
3034
*/
3135
private applyFilters(
3236
scopes: ExtractScopes<typeof Event>,
@@ -37,12 +41,14 @@ export default class EventsController {
3741
scopes.onDate(filters.date)
3842
} else {
3943
if (filters.before) {
40-
// Filter based on when the event ends, i.e. all events before a certain date
44+
// Filter based on when the event ends, i.e. all events before
45+
// a certain date
4146
scopes.beforeDate(filters.before)
4247
}
4348

4449
if (filters.after) {
45-
// Filter based on when the event start, i.e. all events after a certain date
50+
// Filter based on when the event start, i.e. all events after
51+
// a certain date
4652
scopes.afterDate(filters.after)
4753
}
4854
}
@@ -155,6 +161,8 @@ export default class EventsController {
155161
await event.preload('category')
156162
}
157163

164+
await event.createNotification()
165+
158166
return event.toJSON()
159167
}
160168

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Implements the endpoints that handles notifications
3+
*
4+
* @category Controller
5+
* @module NotificationsController
6+
*/
7+
import { DateTime } from 'luxon'
8+
import { getPageNumber } from 'App/Utils/Paginate'
9+
import Notification from 'App/Models/Notification'
10+
import Subscription from 'App/Models/Subscription'
11+
import { ExtractScopes } from '@ioc:Adonis/Lucid/Model'
12+
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
13+
import PaginationValidator from 'App/Validators/PaginationValidator'
14+
import { getNotification, getValidatedData } from 'App/Utils/Request'
15+
import InvalidPushTokenException from 'App/Exceptions/InvalidPushTokenException'
16+
import NotificationFilterValidator from 'App/Validators/Notifications/FilterValidator'
17+
18+
export default class NotificationsController {
19+
/**
20+
* Method that applies given filters to notifications
21+
*
22+
* @param scopes - The different scopes that exists in the system
23+
* @param filters - The filter to apply
24+
*/
25+
private applyFilters(
26+
scopes: ExtractScopes<typeof Notification>,
27+
filters: Record<string, DateTime | undefined>
28+
) {
29+
if (filters.after) {
30+
// Filter based on when the event start, i.e. all events after a
31+
// certain date
32+
scopes.afterDate(filters.after)
33+
}
34+
35+
// Order events based on the 'created_at' field
36+
scopes.inOrder()
37+
}
38+
39+
/**
40+
* Method to retrieve all the notifications in the system
41+
* The actual function call is done by a request (CRUD) which are specified
42+
* in `Routes.ts`
43+
*/
44+
public async all({ request }: HttpContextContract) {
45+
const { after, token } = await getValidatedData(request, NotificationFilterValidator, true)
46+
const specified = await getValidatedData(request, PaginationValidator, true)
47+
48+
const query = Notification.query().apply((scopes) => {
49+
this.applyFilters(scopes, { after })
50+
})
51+
52+
if (token) {
53+
const subscriptions = await Subscription.forToken(token)
54+
55+
if (!subscriptions) {
56+
throw new InvalidPushTokenException()
57+
}
58+
59+
query.whereIn(
60+
['nation_id', 'subscription_topic_id'],
61+
subscriptions.map((subscription) => [
62+
subscription.nationId,
63+
subscription.subscriptionTopicId,
64+
])
65+
)
66+
}
67+
68+
const notifications = await query.paginate(getPageNumber(specified.page), specified.amount)
69+
return notifications.toJSON()
70+
}
71+
72+
public async index({ request }: HttpContextContract) {
73+
return getNotification(request).toJSON()
74+
}
75+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Implements the endpoints that allow users to subscribe and
3+
* unsubscribe to topics of a student nation.
4+
*
5+
* @category Controller
6+
* @module SubscriptionsController
7+
*/
8+
import PushToken from 'App/Models/PushToken'
9+
import Subscription from 'App/Models/Subscription'
10+
import SubscriptionTopic from 'App/Models/SubscriptionTopic'
11+
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
12+
import { getSubscription, getValidatedData } from 'App/Utils/Request'
13+
import PushTokenNotFoundException from 'App/Exceptions/PushTokenNotFoundException'
14+
import SubscriptionQueryValidator from 'App/Validators/Subscriptions/QueryValidator'
15+
import SubscriptionCreateValidator from 'App/Validators/Subscriptions/CreateValidator'
16+
17+
export default class SubscriptionsController {
18+
/**
19+
* Fetch all the available {@link SubscriptionTopic|subscription topics}
20+
*/
21+
public async topics(_: HttpContextContract) {
22+
const topics = await SubscriptionTopic.all()
23+
return topics.map((topic) => topic.toJSON())
24+
}
25+
26+
/**
27+
* Fetch all the subscriptions for a {@link PushToken|push token}
28+
*/
29+
public async all({ request }: HttpContextContract) {
30+
const query = await getValidatedData(request, SubscriptionQueryValidator)
31+
const pushToken = await PushToken.findBy('token', query.token)
32+
33+
if (!pushToken) {
34+
throw new PushTokenNotFoundException()
35+
}
36+
37+
const subscriptions = await Subscription.query().where('pushTokenId', pushToken.id)
38+
39+
return subscriptions.map((subscription) => subscription.toJSON())
40+
}
41+
42+
/**
43+
* Creates a subscription to a topic for a {@link PushToken|push token}
44+
*/
45+
public async create({ request }: HttpContextContract) {
46+
const { oid, topic, token } = await getValidatedData(request, SubscriptionCreateValidator)
47+
const pushToken = await PushToken.firstOrCreate({ token })
48+
const subscription = await Subscription.firstOrCreate({
49+
nationId: oid,
50+
pushTokenId: pushToken.id,
51+
subscriptionTopicId: topic,
52+
})
53+
54+
return subscription.toJSON()
55+
}
56+
57+
/**
58+
* Deletes a subscription to a topic for a {@link PushToken|push token}
59+
*/
60+
public async delete({ request }: HttpContextContract) {
61+
const subscription = getSubscription(request)
62+
await subscription.delete()
63+
}
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* @category Exceptions
3+
* @module InvalidPushTokenException
4+
*/
5+
import DefaultException from 'App/Exceptions/DefaultException'
6+
7+
export default class InvalidPushTokenException extends DefaultException {
8+
constructor() {
9+
super('Invalid push token', 400)
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* @category Exceptions
3+
* @module NotificationNotFoundException
4+
*/
5+
import DefaultException from 'App/Exceptions/DefaultException'
6+
7+
export default class NotificationNotFoundException extends DefaultException {
8+
constructor() {
9+
super('Could not find notification', 404)
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* @category Exceptions
3+
* @module PushTokenNotFoundException
4+
*/
5+
import DefaultException from 'App/Exceptions/DefaultException'
6+
7+
export default class PushTokenNotFoundException extends DefaultException {
8+
constructor() {
9+
super('Could not find push token', 404)
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* @category Exceptions
3+
* @module SubscriptionNotFoundException
4+
*/
5+
import DefaultException from 'App/Exceptions/DefaultException'
6+
7+
export default class SubscriptionNotFoundException extends DefaultException {
8+
constructor() {
9+
super('Could not find subscription', 404)
10+
}
11+
}

app/Middleware/Notification.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Exceptions in this middleware are:
3+
*
4+
* - {@link NotificationNotFoundException}
5+
*
6+
* @category Middleware
7+
* @module NotifactionMiddleware
8+
*
9+
*/
10+
11+
import Notification from 'App/Models/Notification'
12+
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
13+
import NotificationNotFoundException from 'App/Exceptions/NotificationNotFoundException'
14+
15+
export default class NotifactionMiddleware {
16+
public async handle({ request, params }: HttpContextContract, next: () => Promise<void>) {
17+
let notification: Notification | null
18+
19+
notification = await Notification.findBy('id', params.nid)
20+
21+
if (!notification) {
22+
throw new NotificationNotFoundException()
23+
}
24+
25+
request.notification = notification
26+
27+
await next()
28+
}
29+
}

app/Middleware/Subscription.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Exceptions in this middleware are:
3+
*
4+
* - {@link SubscriptionNotFound}
5+
*
6+
* @category Middleware
7+
* @module SubscriptionMiddleware
8+
*
9+
*/
10+
11+
import Subscription from 'App/Models/Subscription'
12+
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
13+
import SubscriptionNotFoundException from 'App/Exceptions/SubscriptionNotFoundException'
14+
15+
export default class SubscriptionMiddleware {
16+
public async handle(
17+
{ request, params }: HttpContextContract,
18+
next: () => Promise<void>,
19+
options: string[]
20+
) {
21+
let subscription: Subscription | null
22+
23+
if (options.includes('preload')) {
24+
subscription = await Subscription.query()
25+
.where('uuid', params.uuid)
26+
.preload('pushToken')
27+
.first()
28+
} else {
29+
subscription = await Subscription.findBy('uuid', params.uuid)
30+
}
31+
32+
if (!subscription) {
33+
throw new SubscriptionNotFoundException()
34+
}
35+
36+
request.subscription = subscription
37+
38+
await next()
39+
}
40+
}

0 commit comments

Comments
 (0)