diff --git a/app/controllers/notifications_controller.ts b/app/controllers/notifications_controller.ts new file mode 100644 index 0000000..cf2afb6 --- /dev/null +++ b/app/controllers/notifications_controller.ts @@ -0,0 +1,90 @@ +import Subscription from '#models/subscription' +import { EVENTS, NotificationService } from '#services/notification_service' +import { subscribeValidator, updateNotificationValidator } from '#validators/notification' +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +@inject() +export default class NotificationsController { + constructor(private service: NotificationService) {} + + // GET /notifications + async index({ auth }: HttpContext) { + return Subscription.query().where('userId', auth.user!.id) + } + + // POST /notifications/subscribe + async subscribe({ request, response, auth }: HttpContext) { + const ua = request.headers()['user-agent'] + if (!ua) { + return response.badRequest({ + message: 'User-Agent header is required', + }) + } + const device = this.service.getUserSignature(ua) + + const data = await request.validateUsing(subscribeValidator) + await Subscription.create({ + userId: auth.user!.id, + device, + events: 0, // Default to no events + data, + failedAttempts: 0, + enabled: true, + }) + } + + // POST /notifications/:id/unsubscribe + async unsubscribe({ request, auth }: HttpContext) { + const subscriptionId = request.param('id') + const subscription = await Subscription.query() + .where('userId', auth.user!.id) + .where('id', subscriptionId) + .firstOrFail() + + await subscription.delete() + return { success: true } + } + + // POST /notifications/:id/ + async update({ request, response, auth }: HttpContext) { + const subscriptionId = request.param('id') + const subscription = await Subscription.query() + .where('userId', auth.user!.id) + .where('id', subscriptionId) + .firstOrFail() + + const { events } = await request.validateUsing(updateNotificationValidator) + // Validate events + if (!events.every((key) => key in EVENTS)) { + return response.badRequest({ + message: 'Invalid events provided', + }) + } + + // Update subscription events + const validEvents = events.map((key) => EVENTS[key as keyof typeof EVENTS]) + subscription.events = this.service.setEvents(validEvents) + await subscription.save() + + return subscription + } + + // POST /notifications/:id/test + async test({ request, response, auth }: HttpContext) { + const subscriptionId = request.param('id') + const subscription = await Subscription.query() + .where('userId', auth.user!.id) + .where('id', subscriptionId) + .firstOrFail() + + const result = await this.service.sendTestNotification(subscription.data) + if (result) { + return { success: true } + } + + return response.internalServerError({ + message: 'Failed to send test notification', + }) + } +} diff --git a/app/models/subscription.ts b/app/models/subscription.ts new file mode 100644 index 0000000..bf8fb31 --- /dev/null +++ b/app/models/subscription.ts @@ -0,0 +1,29 @@ +import { EVENTS } from '#services/notification_service' +import { BaseModel, column } from '@adonisjs/lucid/orm' +import type { PushSubscription } from 'web-push' + +export default class Subscription extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column({ serializeAs: null }) + declare userId: number + + @column() + declare device: string + + @column({ + serialize: (events: number) => + Object.entries(EVENTS).map(([key, value]) => ({ id: key, enabled: (events & value) !== 0 })), + }) + declare events: number + + @column({ serializeAs: null }) + declare data: PushSubscription + + @column({ serializeAs: null }) + declare failedAttempts: number + + @column() + declare enabled: boolean +} diff --git a/app/services/notification_service.ts b/app/services/notification_service.ts new file mode 100644 index 0000000..f7557bb --- /dev/null +++ b/app/services/notification_service.ts @@ -0,0 +1,163 @@ +import Colle from '#models/colle' +import Subscription from '#models/subscription' +import env from '#start/env' +import { DateTime } from 'luxon' +import { UAParser } from 'ua-parser-js' +import webpush, { PushSubscription } from 'web-push' + +export const EVENTS = { + SYSTEM: 1 << 0, + + COLLE_ADDED: 1 << 1, + COLLE_REMOVED: 1 << 2, + COLLE_UPDATED: 1 << 3, + + GRADE_ADDED: 1 << 4, + GRADE_UPDATED: 1 << 5, + + ROOM_UPDATED: 1 << 6, +} + +type Event = (typeof EVENTS)[keyof typeof EVENTS] +type NotificationId = keyof typeof NOTIFICATIONS + +export class NotificationService { + constructor() { + webpush.setVapidDetails( + env.get('VAPID_DETAILS'), + env.get('VAPID_PUBLIC_KEY'), + env.get('VAPID_PRIVATE_KEY') + ) + } + + public setEvents(events: Event[]): number { + return events.reduce((acc, event) => acc | event, 0) + } + + public hasEvent(events: number, event: Event): boolean { + return (events & event) !== 0 + } + + private pushNotification(subscription: PushSubscription, payload: Record) { + return webpush.sendNotification(subscription, JSON.stringify(payload)).catch((err) => { + console.error('Error sending notification:', err) + return false + }) + } + + public async sendNotification(notificationId: NotificationId, colle: Colle) { + const payload = Object.assign(DEFAULT_NOTIFICATION, NOTIFICATIONS[notificationId](colle)) + + const subscriptions = await Subscription.query() + .where('enabled', true) + .where('userId', colle.studentId) + // TODO: Check if working?? + .whereRaw(`(events & ${EVENTS[notificationId]}) > 0`) + + for (const subscription of subscriptions) { + await this.pushNotification(subscription.data, payload) + // TODO: Count failed attempts and disable subscription if too many failures + } + } + + public async sendTestNotification(subscription: PushSubscription) { + const payload = Object.assign(DEFAULT_NOTIFICATION, { + title: 'Test Notification', + body: 'Ceci est une notification de test.', + }) + + return this.pushNotification(subscription, payload) + } + + public getUserSignature(uaString: string) { + const parser = new UAParser(uaString) + const browser = parser.getBrowser() + const os = parser.getOS() + const device = parser.getDevice() + + const browserStr = browser.name + ? `${browser.name} ${browser.version?.split('.')[0] || ''}`.trim() + : '' + const osStr = os.name ? `${os.name} ${os.version || ''}`.trim() : '' + const deviceStr = device.model + ? `${device.vendor || ''} ${device.model}`.trim() + : device.type + ? device.type + : 'Desktop' + + return [deviceStr, osStr, browserStr].filter(Boolean).join(' | ') + } +} + +const NOTIFICATIONS = { + COLLE_ADDED: (colle: Colle) => ({ + title: 'Nouvelle colle', + body: `Colle de ${colle.subject} ajoutée le ${formatDate(colle.date)}.`, + data: { + id: colle.id, + }, + actions: [OPEN_ACION, HOME_ACTION], + }), + COLLE_REMOVED: (colle: Colle) => ({ + title: 'Colle supprimée', + body: `Votre colle de ${colle.subject}, le ${formatDate(colle.date)} a été supprimée.`, + actions: [HOME_ACTION], + }), + COLLE_UPDATED: (colle: Colle) => ({ + title: 'Colle modifiée', + body: `Votre colle de ${colle.subject} du ${formatDate(colle.date)} a été modifiée.`, + data: { + id: colle.id, + }, + actions: [OPEN_ACION, HOME_ACTION], + }), + + GRADE_ADDED: (colle: Colle) => ({ + title: 'Nouvelle note', + body: `Colle de ${colle.subject} : ${colle.grade}/20`, + data: { + id: colle.id, + }, + actions: [OPEN_ACION, HOME_ACTION], + }), + GRADE_UPDATED: (colle: Colle) => ({ + title: 'Note modifiée', + body: `Colle de ${colle.subject} : ${colle.grade}/20`, + data: { + id: colle.id, + }, + actions: [OPEN_ACION, HOME_ACTION], + }), + + ROOM_UPDATED: (colle: Colle) => ({ + title: 'Salle modifiée', + body: `Colle de ${colle.subject} en ${colle.room}.`, + data: { + id: colle.id, + }, + actions: [OPEN_ACION, HOME_ACTION], + }), +} + +const OPEN_ACION = { + action: 'open', + title: 'Ouvrir', +} + +const HOME_ACTION = { + action: 'home', + title: 'Mes colles', +} + +const DEFAULT_NOTIFICATION = { + title: 'Notification', + body: 'Vous avez une nouvelle notification.', + requireInteraction: true, + icon: env.get('PUBLIC_URL') + '/web-app-manifest-192x192.png', +} + +const formatDate = (date: DateTime) => + date.toJSDate().toLocaleDateString('fr-FR', { + month: '2-digit', + day: '2-digit', + }) diff --git a/app/validators/notification.ts b/app/validators/notification.ts new file mode 100644 index 0000000..cf489bd --- /dev/null +++ b/app/validators/notification.ts @@ -0,0 +1,17 @@ +import vine from '@vinejs/vine' + +export const subscribeValidator = vine.compile( + vine.object({ + endpoint: vine.string().url(), + keys: vine.object({ + p256dh: vine.string(), + auth: vine.string(), + }), + }) +) + +export const updateNotificationValidator = vine.compile( + vine.object({ + events: vine.array(vine.string()) + }) +) diff --git a/package.json b/package.json index 4800aa5..d6afc98 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@swc/core": "1.11.24", "@types/luxon": "^3.6.2", "@types/node": "^22.15.18", + "@types/web-push": "^3.6.4", "eslint": "^9.26.0", "hot-hook": "^0.4.0", "pino-pretty": "^13.0.0", @@ -66,7 +67,9 @@ "edge.js": "^6.2.1", "luxon": "^3.6.1", "pg": "^8.16.0", - "reflect-metadata": "^0.2.2" + "reflect-metadata": "^0.2.2", + "ua-parser-js": "^2.0.4", + "web-push": "^3.6.7" }, "hotHook": { "boundaries": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42ed410..ab11817 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,12 @@ importers: reflect-metadata: specifier: ^0.2.2 version: 0.2.2 + ua-parser-js: + specifier: ^2.0.4 + version: 2.0.4 + web-push: + specifier: ^3.6.7 + version: 3.6.7 devDependencies: '@adonisjs/assembler': specifier: ^7.8.2 @@ -90,6 +96,9 @@ importers: '@types/node': specifier: ^22.15.18 version: 22.16.0 + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 eslint: specifier: ^9.26.0 version: 9.30.1 @@ -806,6 +815,9 @@ packages: '@types/luxon@3.6.2': resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@22.16.0': resolution: {integrity: sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==} @@ -824,6 +836,9 @@ packages: '@types/validator@13.15.2': resolution: {integrity: sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@typescript-eslint/eslint-plugin@8.35.1': resolution: {integrity: sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -918,6 +933,10 @@ packages: peerDependencies: '@adonisjs/core': ^6.2.0 + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -958,6 +977,9 @@ packages: as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -972,6 +994,9 @@ packages: async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1003,6 +1028,9 @@ packages: orchid-orm: optional: true + bn.js@4.12.2: + resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1018,6 +1046,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + builtin-modules@5.0.0: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} @@ -1127,6 +1158,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -1225,6 +1260,10 @@ packages: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -1237,6 +1276,9 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1256,6 +1298,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + edge-error@4.0.2: resolution: {integrity: sha512-jB76VYn8wapDHKHSOmP3vbKLoa77RJYsTLNmfl8+cuCD69uxZtP3h+kqV+Prw/YkYmN7yHyp4IApE15pDByk0A==} engines: {node: '>=18.16.0'} @@ -1324,6 +1369,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1518,6 +1567,10 @@ packages: resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==} engines: {node: '>= 18'} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + formdata-node@6.0.3: resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==} engines: {node: '>= 18'} @@ -1608,6 +1661,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1637,6 +1694,14 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -1764,6 +1829,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -1823,6 +1891,12 @@ packages: resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==} engines: {node: '>=12.20'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1970,6 +2044,9 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2002,6 +2079,15 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} @@ -2587,6 +2673,9 @@ packages: resolution: {integrity: sha512-IKJ6EzuPPWtKtEIEPpIdXv9j5j2LGJEYk0CKY2efgKoYKLBiZdh6iQkLVBow/CB3phyWAWCyk+bZeaimJn6uRQ==} engines: {node: '>=14.16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + truncatise@0.0.8: resolution: {integrity: sha512-cXzueh9pzBCsLzhToB4X4gZCb3KYkrsAcBAX97JnazE74HOl3cpBJYEV7nabHeG/6/WXCU5Yujlde/WPBUwnsg==} @@ -2640,6 +2729,13 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.4: + resolution: {integrity: sha512-XiBOnM/UpUq21ZZ91q2AVDOnGROE6UQd37WrO9WBgw4u2eGvUCNOheMmZ3EfEUj7DLHr8tre+Um/436Of/Vwzg==} + hasBin: true + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -2694,6 +2790,17 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3531,6 +3638,11 @@ snapshots: '@types/luxon@3.6.2': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.16.0 + form-data: 4.0.4 + '@types/node@22.16.0': dependencies: undici-types: 6.21.0 @@ -3547,6 +3659,10 @@ snapshots: '@types/validator@13.15.2': {} + '@types/web-push@3.6.4': + dependencies: + '@types/node': 22.16.0 + '@typescript-eslint/eslint-plugin@8.35.1(@typescript-eslint/parser@8.35.1(eslint@9.30.1)(typescript@5.8.3))(eslint@9.30.1)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -3675,6 +3791,8 @@ snapshots: '@poppinss/utils': 6.10.0 got: 14.4.7 + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3708,6 +3826,13 @@ snapshots: dependencies: printable-characters: 1.0.42 + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.2 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} astring@1.9.0: {} @@ -3720,6 +3845,8 @@ snapshots: dependencies: retry: 0.13.1 + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} balanced-match@1.0.2: {} @@ -3740,6 +3867,8 @@ snapshots: ioredis: 5.6.1 knex: 3.1.0(pg@8.16.3) + bn.js@4.12.2: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3760,6 +3889,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) + buffer-equal-constant-time@1.0.1: {} + builtin-modules@5.0.0: {} bytes@3.1.2: {} @@ -3863,6 +3994,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@10.0.1: {} common-path-prefix@3.0.0: {} @@ -3933,12 +4068,16 @@ snapshots: defer-to-connect@2.0.1: {} + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} destroy@1.2.0: {} + detect-europe-js@0.1.2: {} + diff-sequences@29.6.3: {} diff@4.0.2: {} @@ -3953,6 +4092,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + edge-error@4.0.2: {} edge-lexer@6.0.3: @@ -4021,6 +4164,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -4235,6 +4385,14 @@ snapshots: form-data-encoder@4.1.0: {} + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + formdata-node@6.0.3: {} forwarded@0.2.0: {} @@ -4326,6 +4484,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -4361,6 +4523,15 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + http_ece@1.2.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + human-signals@8.0.1: {} ical-generator@7.2.0(@types/luxon@3.6.2)(@types/node@22.16.0)(dayjs@1.11.13)(luxon@3.6.1): @@ -4443,6 +4614,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-standalone-pwa@0.1.1: {} + is-stream@4.0.1: {} is-unicode-supported@2.1.0: {} @@ -4482,6 +4655,17 @@ snapshots: junk@4.0.1: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.0: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4605,6 +4789,8 @@ snapshots: min-indent@1.0.1: {} + minimalistic-assert@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -4627,6 +4813,10 @@ snapshots: negotiator@0.6.3: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.19: {} nodemailer@6.10.1: {} @@ -5156,6 +5346,8 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tr46@0.0.3: {} + truncatise@0.0.8: {} ts-api-utils@2.1.0(typescript@5.8.3): @@ -5213,6 +5405,18 @@ snapshots: typescript@5.8.3: {} + ua-is-frozen@0.1.2: {} + + ua-parser-js@2.0.4: + dependencies: + '@types/node-fetch': 2.6.13 + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + node-fetch: 2.7.0 + ua-is-frozen: 0.1.2 + transitivePeerDependencies: + - encoding + uglify-js@3.19.3: {} uid-safe@2.1.5: @@ -5252,6 +5456,23 @@ snapshots: vary@1.1.2: {} + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.0 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/start/env.ts b/start/env.ts index 4419d4a..f428a6b 100644 --- a/start/env.ts +++ b/start/env.ts @@ -65,4 +65,10 @@ export default await Env.create(new URL('../', import.meta.url), { TURNSTILE_SECRET: Env.schema.string.optional(), SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const), + + PUBLIC_URL: Env.schema.string({ format: 'url' }), + + VAPID_DETAILS: Env.schema.string(), + VAPID_PUBLIC_KEY: Env.schema.string(), + VAPID_PRIVATE_KEY: Env.schema.string(), }) diff --git a/start/routes.ts b/start/routes.ts index 586e43d..b416659 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -37,12 +37,25 @@ const SubjectsController = () => import('#controllers/subjects_controller') router.get('/subjects', [SubjectsController, 'index']).use(middleware.auth()) const CollesController = () => import('#controllers/colles_controller') -router.group(() => { - // TODO: PRIVATE ROUTES - router.post('/', [CollesController, 'create']) - router.post('/upcoming', [CollesController, 'createUpcoming']) - router.post('/:colleId/refresh', [CollesController, 'refresh']).use(middleware.auth()) - router.get('/', [CollesController, 'index']).use(middleware.auth()) - router.get('/:colleId', [CollesController, 'show']).use(middleware.auth()) -} -).prefix('/colles') +router + .group(() => { + // TODO: PRIVATE ROUTES + router.post('/', [CollesController, 'create']) + router.post('/upcoming', [CollesController, 'createUpcoming']) + router.post('/:colleId/refresh', [CollesController, 'refresh']).use(middleware.auth()) + router.get('/', [CollesController, 'index']).use(middleware.auth()) + router.get('/:colleId', [CollesController, 'show']).use(middleware.auth()) + }) + .prefix('/colles') + +const NotificationsController = () => import('#controllers/notifications_controller') +router + .group(() => { + router.get('/', [NotificationsController, 'index']) + router.post('/subscribe', [NotificationsController, 'subscribe']) + router.post('/:id/unsubscribe', [NotificationsController, 'unsubscribe']) + router.post('/:id', [NotificationsController, 'update']) + router.post('/:id/test', [NotificationsController, 'test']).use(middleware.auth()) + }) + .prefix('/notifications') + .use(middleware.auth())