feat: add notifications

This commit is contained in:
Nathan Lamy 2025-08-20 12:22:11 +02:00
parent 06fc137057
commit 35a8191938
8 changed files with 552 additions and 10 deletions

View file

@ -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',
})
}
}

View file

@ -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
}

View file

@ -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<string, any>) {
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',
})

View file

@ -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())
})
)

View file

@ -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": [

221
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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(),
})

View file

@ -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())