mirror of
https://github.com/michivonah/themepark-assistant.git
synced 2025-12-22 22:16:29 +01:00
implement endpoints for subscribing & unsubscribing to attractions
This commit is contained in:
parent
a185b29875
commit
6a60e3c10a
4 changed files with 156 additions and 0 deletions
|
|
@ -2,6 +2,7 @@ import { Hono } from 'hono'
|
||||||
import { authHandler, initAuthConfig, verifyAuth } from '@hono/auth-js'
|
import { authHandler, initAuthConfig, verifyAuth } from '@hono/auth-js'
|
||||||
import { getUser } from './lib/user-auth'
|
import { getUser } from './lib/user-auth'
|
||||||
import GitHub from '@auth/core/providers/github'
|
import GitHub from '@auth/core/providers/github'
|
||||||
|
import attraction from './routes/attraction'
|
||||||
import notification from './routes/notification'
|
import notification from './routes/notification'
|
||||||
import logbook from './routes/logbook'
|
import logbook from './routes/logbook'
|
||||||
import themepark from './routes/themepark'
|
import themepark from './routes/themepark'
|
||||||
|
|
@ -32,6 +33,7 @@ app.get('/protected', async (c) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// define routes & export app
|
// define routes & export app
|
||||||
|
app.route('/attraction', attraction)
|
||||||
app.route('/notification', notification)
|
app.route('/notification', notification)
|
||||||
app.route('/logbook', logbook)
|
app.route('/logbook', logbook)
|
||||||
app.route('/themepark', themepark)
|
app.route('/themepark', themepark)
|
||||||
|
|
|
||||||
130
api/src/routes/attraction.ts
Normal file
130
api/src/routes/attraction.ts
Normal file
|
|
@ -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<NotificationMethodSelect, "userId">;
|
||||||
|
|
||||||
|
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<typeof getDbContext>, methodId: number, userId: number): Promise<NotificationMethodUser>{
|
||||||
|
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<string, unknown>).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
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
|
||||||
|
// Client errors
|
||||||
export class UserInactiveError extends HTTPException{
|
export class UserInactiveError extends HTTPException{
|
||||||
constructor(){
|
constructor(){
|
||||||
super(403, { message: 'User is currently disabled.' })
|
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{
|
export class DatabaseError extends HTTPException{
|
||||||
constructor(){
|
constructor(){
|
||||||
super(500, { message: 'Internal Database Error' })
|
super(500, { message: 'Internal Database Error' })
|
||||||
|
|
|
||||||
10
api/src/types/response.ts
Normal file
10
api/src/types/response.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export class Message<T = {}>{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue