diff --git a/api/src/index.ts b/api/src/index.ts index a375971..f461e9e 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { authHandler, initAuthConfig, verifyAuth } from '@hono/auth-js' import { getUser } from './lib/user-auth' import GitHub from '@auth/core/providers/github' +import attraction from './routes/attraction' import notification from './routes/notification' import logbook from './routes/logbook' import themepark from './routes/themepark' @@ -32,6 +33,7 @@ app.get('/protected', async (c) => { }) // define routes & export app +app.route('/attraction', attraction) app.route('/notification', notification) app.route('/logbook', logbook) app.route('/themepark', themepark) diff --git a/api/src/routes/attraction.ts b/api/src/routes/attraction.ts new file mode 100644 index 0000000..f523f54 --- /dev/null +++ b/api/src/routes/attraction.ts @@ -0,0 +1,130 @@ +import { Hono, Context } from 'hono' +import { getDbContext } from '../db/client' +import { attractionNotification, notificationMethod } from '../db/schema' +import { and, eq } from 'drizzle-orm' +import { DatabaseError, InvalidParameter, MissingParameter } from '../types/error' +import { getUser } from '../lib/user-auth' +import { Message } from '../types/response' +import { NotificationMethodSelect } from '../types/notification-method' + +type NotificationMethodUser = Pick; + +const app = new Hono() + +/** + * Checks if request has notificationMethodId parameter + * @param c Request context + * @returns if available notificationMethodId, else undefined + */ +function getNotificationMethodId(c: Context): number | undefined{ + const str = c.req.query('notificationMethodId'); + if(!str) return undefined; + return parseInt(str); +} + +/** + * Checks if a specified user is the owner of a notification method and returns the owner if valid + * @param db DB connection (already defined as variable/const) + * @param methodId notificationMethodId to check + * @param userId User to check wheter it is the owner + * @returns Object with the owners userId + */ +async function getNotificationMethodOwner(db: ReturnType, methodId: number, userId: number): Promise{ + try{ + const method = await db.select({ + userId: notificationMethod.userId + }).from(notificationMethod) + .where(eq(notificationMethod.id, methodId)).get(); + + if(!method || method.userId !== userId) throw new InvalidParameter('notificationMethodId'); + else return method; + } + catch(e){ + if(e instanceof InvalidParameter) throw e; + throw new DatabaseError(); + } +} + +/** + * Checks if drizzle db error has a message cause + * @param e Thrown error + * @returns Boolean if message cause exists or not + */ +function hasMessageCause(e: unknown): e is Error & { cause: { message: string }}{ + if(!(e instanceof Error)) return false; + const c = e.cause; + if (typeof c !== 'object' || c === null) return false; + return( + 'message' in c && typeof (c as Record).message === 'string' + ); +} + +/** + * Subscribe to waittime notifications from a specified attraction + */ +app.post('/:id/subscribe', async (c) => { + const attractionId = parseInt(c.req.param('id')); + const db = getDbContext(c) + const user = await getUser(c); + + const notificationMethodId = getNotificationMethodId(c); + if(!notificationMethodId) throw new MissingParameter('notificationMethodId'); + + const method = await getNotificationMethodOwner(db, notificationMethodId, user.id); + + if(!method || method.userId !== user.id) throw new InvalidParameter('notificationMethodId'); + + try{ + const res = await db.insert(attractionNotification).values({ + userId: user.id, + attractionId: attractionId, + notificationMethodId: notificationMethodId + }).returning().onConflictDoNothing(); + + const message = res.length !== 0 + ? `Successfully subscribed to attraction with id ${attractionId}.` + : `Your are already subscribed to attraction ${attractionId}. No changes made.` + + return c.json(new Message(message, {attractionId, notificationMethodId})); + } + catch(e){ + if(hasMessageCause(e) && e.cause.message.includes('FOREIGN KEY constraint failed')){ + throw new InvalidParameter('attractionId'); + } + throw new DatabaseError(); + } +}) + +/** + * Unsubscribe to waittime notifications from a specified attraction + */ +app.post('/:id/unsubscribe', async (c) => { + const attractionId = parseInt(c.req.param('id')); + const db = getDbContext(c) + const user = await getUser(c); + + const notificationMethodId = getNotificationMethodId(c); + const methodOwner = notificationMethodId ? await getNotificationMethodOwner(db, notificationMethodId, user.id): false; + + const queryConditions = [ + eq(attractionNotification.userId, user.id), + eq(attractionNotification.attractionId, attractionId), + notificationMethodId && methodOwner ? eq(attractionNotification.notificationMethodId, notificationMethodId) : undefined + ].filter(Boolean); + + try{ + const res = await db.delete(attractionNotification).where(and(...queryConditions)).returning(); + const deletedRecords = res.length; + + const message = deletedRecords !== 0 + ? `Successfully deleted ${deletedRecords} attraction subscriptions.` + : `You didn't were subscribed to attraction ${attractionId}. No changes made.` + + return c.json(new Message(message, {attractionId, notificationMethodId, deletedRecords})); + } + catch(e){ + throw new DatabaseError(); + } +}) + +export default app \ No newline at end of file diff --git a/api/src/types/error.ts b/api/src/types/error.ts index b4a0c94..23c4767 100644 --- a/api/src/types/error.ts +++ b/api/src/types/error.ts @@ -1,5 +1,6 @@ import { HTTPException } from "hono/http-exception"; +// Client errors export class UserInactiveError extends HTTPException{ constructor(){ super(403, { message: 'User is currently disabled.' }) @@ -12,6 +13,19 @@ export class MissingMailError extends HTTPException{ } } +export class MissingParameter extends HTTPException{ + constructor(paramName: string){ + super(400, { message: `Request parameter '${paramName}' missing` }) + } +} + +export class InvalidParameter extends HTTPException{ + constructor(paramName: string){ + super(400, { message: `Provided parameter '${paramName}' is invalid.` }) + } +} + +// Server side errors export class DatabaseError extends HTTPException{ constructor(){ super(500, { message: 'Internal Database Error' }) diff --git a/api/src/types/response.ts b/api/src/types/response.ts new file mode 100644 index 0000000..9479117 --- /dev/null +++ b/api/src/types/response.ts @@ -0,0 +1,10 @@ +export class Message{ + message: string; + details?: T; + detailsInline?: boolean; + + constructor(message: string, details?: T, detailsInline: boolean = false){ + this.message = message; + if(details) detailsInline ? Object.assign(this, details) : this.details = details; + } +} \ No newline at end of file