Compare commits

...

3 commits

10 changed files with 255 additions and 50 deletions

View file

@ -6,24 +6,24 @@ export const attraction = sqliteTable('attraction', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(), name: text().notNull(),
apiCode: text('api_code').notNull().unique(), apiCode: text('api_code').notNull().unique(),
themeparkId: integer('themepark_id').notNull().references(() => themepark.id) themeparkId: integer('themepark_id').notNull().references(() => themepark.id, {onDelete: 'cascade'})
}, (t) => [ }, (t) => [
unique().on(t.apiCode, t.themeparkId) unique().on(t.apiCode, t.themeparkId)
]) ])
export const attractionNotification = sqliteTable('attraction_notification', { export const attractionNotification = sqliteTable('attraction_notification', {
id: integer().primaryKey({ autoIncrement: true}), id: integer().primaryKey({ autoIncrement: true}),
userId: integer('user_id').notNull().references(() => user.id), userId: integer('user_id').notNull().references(() => user.id, {onDelete: 'cascade'}),
attractionId: integer('attraction_id').notNull().references(() => attraction.id), attractionId: integer('attraction_id').notNull().references(() => attraction.id, {onDelete: 'cascade'}),
notificationMethodId: integer('notification_method_id').notNull().references(() => notificationMethod.id) notificationMethodId: integer('notification_method_id').notNull().references(() => notificationMethod.id, {onDelete: 'cascade'})
}, (t) => [ }, (t) => [
unique().on(t.userId, t.attractionId, t.notificationMethodId) unique().on(t.userId, t.attractionId, t.notificationMethodId)
]) ])
export const logbook = sqliteTable('logbook', { export const logbook = sqliteTable('logbook', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => user.id), userId: integer('user_id').notNull().references(() => user.id, {onDelete: 'cascade'}),
attractionId: integer('attraction_id').notNull().references(() => attraction.id), attractionId: integer('attraction_id').notNull().references(() => attraction.id, {onDelete: 'cascade'}),
timestamp: integer().notNull(), // unix timecode timestamp: integer().notNull(), // unix timecode
expectedWaittime: integer(), expectedWaittime: integer(),
realWaittime: integer() realWaittime: integer()
@ -35,8 +35,8 @@ export const notificationMethod = sqliteTable('notification_method', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
webhookUrl: text('webhook_url').notNull(), webhookUrl: text('webhook_url').notNull(),
shownName: text().notNull(), shownName: text().notNull(),
userId: integer('user_id').notNull().references(() => user.id), userId: integer('user_id').notNull().references(() => user.id, {onDelete: 'cascade'}),
notificationProviderId: integer('notification_provider_id').notNull().references(() => notificationProvider.id), notificationProviderId: integer('notification_provider_id').notNull().references(() => notificationProvider.id, {onDelete: 'cascade'}),
}, (t) => [ }, (t) => [
unique().on(t.webhookUrl, t.userId, t.notificationProviderId) unique().on(t.webhookUrl, t.userId, t.notificationProviderId)
]) ])

View file

@ -28,7 +28,7 @@ export class InvalidParameter extends HTTPException{
super(400, { message: super(400, { message:
paramName paramName
? `Provided parameter '${paramName}' is invalid.` ? `Provided parameter '${paramName}' is invalid.`
: 'Provided invalid parameter.' : 'Provided invalid request parameter(s) or some required parameter is missing.'
}) })
} }
} }

View file

@ -6,6 +6,7 @@ import attraction from './routes/attraction'
import notification from './routes/notification-method' import notification from './routes/notification-method'
import logbook from './routes/logbook' import logbook from './routes/logbook'
import themepark from './routes/themepark' import themepark from './routes/themepark'
import user from './routes/user'
import cronRouter from './jobs/cron' import cronRouter from './jobs/cron'
// create app // create app
@ -37,6 +38,7 @@ app.route('/attraction', attraction)
app.route('/notification-method', notification) app.route('/notification-method', notification)
app.route('/logbook', logbook) app.route('/logbook', logbook)
app.route('/themepark', themepark) app.route('/themepark', themepark)
app.route('/user', user)
export default { export default {
fetch: app.fetch, fetch: app.fetch,
scheduled: cronRouter, scheduled: cronRouter,

View file

@ -1,9 +1,17 @@
import { cache } from 'hono/cache' import { cache } from 'hono/cache'
/** /**
* Cache unit to use for multiple endpoints as needed * Cache unit to use for multiple endpoints as needed (TTL: 86400s)
*/ */
export const responseCache = cache({ export const responseCache = cache({
cacheName: 'themepark-assistant', cacheName: 'themepark-assistant',
cacheControl: 'max-age=86400' cacheControl: 'max-age=86400'
}); });
/**
* Cache for dynamic data (TTL: 30s)
*/
export const dynamicCache = cache({
cacheName: 'themepark-assistant-dynamic',
cacheControl: 'max-age=30s'
});

View file

@ -8,8 +8,23 @@ import { InvalidParameter } from '../errors'
* @param schema Zod Validation scheme (docs: https://zod.dev/api) * @param schema Zod Validation scheme (docs: https://zod.dev/api)
* @returns zValidator for running the validation * @returns zValidator for running the validation
*/ */
export default function httpZValidator<T extends z.ZodTypeAny>(type: 'query' | 'json' | 'param' = 'query', schema: T){ export function httpZValidator<T extends z.ZodTypeAny>(type: 'query' | 'json' | 'param' = 'query', schema: T){
return zValidator(type, schema, (result, c) => { return zValidator(type, schema, (result, c) => {
if(!result.success) throw new InvalidParameter(); if(!result.success) throw new InvalidParameter();
}) })
} }
// Predefined validators
/**
* Validates if id is a number (using zod)
*/
export const idValidator = httpZValidator('param', z.strictObject({
id: z.coerce.number()
}));
/**
* Validates if notificationMethodId is number (using zod)
*/
export const notificationMethodIdValidator = httpZValidator('query', z.strictObject({
notificationMethodId: z.coerce.number()
}));

View file

@ -1,4 +1,4 @@
import { Hono, Context } from 'hono' import { Hono } from 'hono'
import { getDbContext } from '../db/client' import { getDbContext } from '../db/client'
import { attractionNotification } from '../db/schema' import { attractionNotification } from '../db/schema'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
@ -6,19 +6,14 @@ import { DatabaseError, InvalidParameter, MissingParameter } from '../errors'
import { getUser } from '../lib/user-auth' import { getUser } from '../lib/user-auth'
import { Message } from '../types/response' import { Message } from '../types/response'
import { getNotificationMethodOwner } from '../lib/check-notification-method-owner' import { getNotificationMethodOwner } from '../lib/check-notification-method-owner'
import { httpZValidator, idValidator, notificationMethodIdValidator } from '../lib/http-z-validator'
import * as z from 'zod'
const app = new Hono() 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 drizzle db error has a message cause * Checks if drizzle db error has a message cause
@ -37,13 +32,12 @@ function hasMessageCause(e: unknown): e is Error & { cause: { message: string }}
/** /**
* Subscribe to waittime notifications from a specified attraction * Subscribe to waittime notifications from a specified attraction
*/ */
app.post('/:id/subscribe', async (c) => { app.post('/:id/subscribe', idValidator, notificationMethodIdValidator, async (c) => {
const attractionId = parseInt(c.req.param('id')); const attractionId = c.req.valid('param').id;
const db = getDbContext(c) const db = getDbContext(c)
const user = await getUser(c); const user = await getUser(c);
const notificationMethodId = getNotificationMethodId(c); const notificationMethodId = c.req.valid('query').notificationMethodId;
if(!notificationMethodId) throw new MissingParameter('notificationMethodId');
const method = await getNotificationMethodOwner(db, notificationMethodId, user.id); const method = await getNotificationMethodOwner(db, notificationMethodId, user.id);
@ -73,12 +67,12 @@ app.post('/:id/subscribe', async (c) => {
/** /**
* Unsubscribe to waittime notifications from a specified attraction * Unsubscribe to waittime notifications from a specified attraction
*/ */
app.post('/:id/unsubscribe', async (c) => { app.post('/:id/unsubscribe', idValidator, notificationMethodIdValidator, async (c) => {
const attractionId = parseInt(c.req.param('id')); const attractionId = c.req.valid('param').id;
const db = getDbContext(c) const db = getDbContext(c)
const user = await getUser(c); const user = await getUser(c);
const notificationMethodId = getNotificationMethodId(c); const notificationMethodId = c.req.valid('query').notificationMethodId;
const methodOwner = notificationMethodId ? await getNotificationMethodOwner(db, notificationMethodId, user.id): false; const methodOwner = notificationMethodId ? await getNotificationMethodOwner(db, notificationMethodId, user.id): false;
const queryConditions = [ const queryConditions = [

View file

@ -1,13 +1,150 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { DatabaseError } from '../errors'
import { getDbContext } from '../db/client'
import { logbook } from '../db/schema'
import { and, eq } from 'drizzle-orm'
import { getUser } from '../lib/user-auth'
import { Message } from '../types/response'
import { httpZValidator } from '../lib/http-z-validator'
import * as z from 'zod'
const app = new Hono() const app = new Hono()
app.get('/list', (c) => { /**
return c.json( * Lists all logbook entries of the logged in user
{ */
message: 'List all logbook entries' app.get('/list', async (c) => {
} const db = getDbContext(c);
) const user = await getUser(c);
try{
const logbookEntries = await db.select({
entryId: logbook.id,
attractionId: logbook.attractionId,
timestamp: logbook.timestamp,
expectedWaittime: logbook.expectedWaittime,
realWaittime: logbook.realWaittime
}).from(logbook)
.where(eq(logbook.userId, user.id));
return c.json(logbookEntries);
}
catch{
throw new DatabaseError();
}
})
/**
* Adds new entry to the logbook of the current user
*/
app.post('/add-entry', httpZValidator('query', z.strictObject({
attractionId: z.coerce.number(),
timestamp: z.coerce.number().min(0).positive(),
expectedWaittime: z.coerce.number().optional(),
realWaittime: z.coerce.number().optional()
})),
async (c) => {
const db = getDbContext(c);
const user = await getUser(c);
const params = c.req.valid('query');
try{
const res = await db.insert(logbook).values({
userId: user.id,
attractionId: params.attractionId,
timestamp: params.timestamp,
expectedWaittime: params.expectedWaittime,
realWaittime: params.realWaittime
}).onConflictDoNothing().returning();
const newEntry = res[0];
const message = res.length > 0
? 'Added new entry to logbook.'
: 'Entry already exists. No changes made.';
return c.json(new Message(message, {
entryId: newEntry.id,
attractionId: newEntry.attractionId,
timestamp: newEntry.timestamp,
expectedWaittime: newEntry.expectedWaittime,
realWaittime: newEntry.realWaittime
}));
}
catch{
throw new DatabaseError();
}
})
/**
* Updates waittime information of specified entry (by entryId)
*/
app.put('update-entry', httpZValidator('query', z.strictObject({
entryId: z.coerce.number(),
expectedWaittime: z.coerce.number().optional(),
realWaittime: z.coerce.number().optional()
}).refine((data) => data.expectedWaittime || data.realWaittime)),
async (c) => {
const db = getDbContext(c);
const user = await getUser(c);
const params = c.req.valid('query');
try{
const res = await db.update(logbook).set({
expectedWaittime: params.expectedWaittime,
realWaittime: params.realWaittime
}).where(and(
eq(logbook.userId, user.id),
eq(logbook.id, params.entryId)
)).returning();
const modifiedEntry = res[0];
return c.json(res.length > 0
? new Message('Updated logbook entry.', {
entryId: modifiedEntry.id,
attractionId: modifiedEntry.attractionId,
timestamp: modifiedEntry.timestamp,
expectedWaittime: modifiedEntry.expectedWaittime,
realWaittime: modifiedEntry.realWaittime
})
: new Message('Requested entry does not exist. No changes made.')
);
}
catch{
throw new DatabaseError();
}
})
/**
* Removes specified logbook entry by entryId
*/
app.delete('remove-entry', httpZValidator('query', z.strictObject({
entryId: z.coerce.number()
})),
async (c) => {
const db = getDbContext(c);
const user = await getUser(c);
const params = c.req.valid('query');
try{
const res = await db.delete(logbook).where(
and(
eq(logbook.userId, user.id),
eq(logbook.id, params.entryId)
)
).returning();
const message = res.length > 0
? `Logbook entry with id ${params.entryId} was removed.`
: `No logbook entry with id ${params.entryId} found. No changes made.`;
return c.json(new Message(message));
}
catch{
throw new DatabaseError();
}
}) })
export default app export default app

View file

@ -5,6 +5,9 @@ import { eq, and } from 'drizzle-orm'
import { getUser } from '../lib/user-auth' import { getUser } from '../lib/user-auth'
import { DatabaseError, InvalidParameter, MissingParameter } from '../errors' import { DatabaseError, InvalidParameter, MissingParameter } from '../errors'
import { Message } from '../types/response' import { Message } from '../types/response'
import { responseCache, dynamicCache } from '../lib/cache'
import { httpZValidator, idValidator } from '../lib/http-z-validator'
import * as z from 'zod'
const app = new Hono() const app = new Hono()
@ -51,7 +54,7 @@ app.get('/list', async (c) => {
}) })
/** Lists all available notification providers */ /** Lists all available notification providers */
app.get('/list-providers', async (c) => { app.get('/list-providers', responseCache, async (c) => {
const db = getDbContext(c); const db = getDbContext(c);
try{ try{
@ -68,21 +71,24 @@ app.get('/list-providers', async (c) => {
}) })
/** Creates a new notification method from url, name & provider */ /** Creates a new notification method from url, name & provider */
app.post('/add-method', async (c) => { app.post('/add-method', httpZValidator('query', z.strictObject({
url: z.string(),
name: z.string(),
provider: z.string()
})),
async (c) => {
const db = getDbContext(c); const db = getDbContext(c);
const user = await getUser(c); const user = await getUser(c);
const { url, name, provider } = c.req.query(); const params = c.req.valid('query');
if(!url || !name || !provider) throw new MissingParameter(); const providerId = await getProviderId(db, params.provider);
const providerId = await getProviderId(db, provider);
if(!providerId) throw new InvalidParameter('provider'); if(!providerId) throw new InvalidParameter('provider');
try{ try{
const newMethod = await db.insert(notificationMethod).values({ const newMethod = await db.insert(notificationMethod).values({
webhookUrl: url, webhookUrl: params.url,
shownName: name, shownName: params.name,
userId: user.id, userId: user.id,
notificationProviderId: providerId notificationProviderId: providerId
}).returning().onConflictDoNothing().get(); }).returning().onConflictDoNothing().get();
@ -103,12 +109,10 @@ app.post('/add-method', async (c) => {
}) })
/** Removes a existing notification method by id (has to be owned by the current user) */ /** Removes a existing notification method by id (has to be owned by the current user) */
app.delete('/remove-method/:id', async (c) => { app.delete('/remove-method/:id', idValidator, async (c) => {
const db = getDbContext(c); const db = getDbContext(c);
const user = await getUser(c); const user = await getUser(c);
const methodId = parseInt(c.req.param('id')); const methodId = c.req.valid('param').id;
if(!methodId) throw new InvalidParameter('id');
try{ try{
const res = await db.delete(notificationMethod).where( const res = await db.delete(notificationMethod).where(

View file

@ -4,6 +4,7 @@ import { themepark, attraction } from '../db/schema'
import { responseCache } from '../lib/cache' import { responseCache } from '../lib/cache'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { DatabaseError } from '../errors' import { DatabaseError } from '../errors'
import { idValidator } from '../lib/http-z-validator'
const app = new Hono() const app = new Hono()
@ -30,8 +31,8 @@ app.get('/list', responseCache, async (c) => {
/** /**
* Lists all attractions from a themepark with their id & name * Lists all attractions from a themepark with their id & name
*/ */
app.get('/list/:id/attraction', responseCache, async (c) => { app.get('/list/:id/attraction', responseCache, idValidator, async (c) => {
const parkId = parseInt(c.req.param('id')); const parkId = c.req.valid('param').id;
const db = getDbContext(c) const db = getDbContext(c)
try{ try{

44
api/src/routes/user.ts Normal file
View file

@ -0,0 +1,44 @@
import { Hono } from 'hono'
import { DatabaseError } from '../errors'
import { getDbContext } from '../db/client'
import { getUser } from '../lib/user-auth'
import { user } from '../db/schema'
import { eq } from 'drizzle-orm'
import { Message } from '../types/response'
import { httpZValidator } from '../lib/http-z-validator'
import * as z from 'zod'
const app = new Hono()
/**
* Deletes user account with all associated data (requires parameter 'confirm' to be true)
* Be careful when using (once your data is delete it cannot be restored)
*/
app.delete('delete-account', httpZValidator('query', z.strictObject({
confirm: z.literal('true') // to prevent unwanted account deletion when hitting the endpoint unintentionally
})),
async (c) => {
const db = getDbContext(c);
const currentUser = await getUser(c);
const params = c.req.valid('query');
try{
const res = await db.delete(user).where(
eq(user.id, currentUser.id)
).returning();
const deletedUser = res[0];
const message = res.length > 0
? `User account ${deletedUser.mail} with all associated data deleted.`
: 'User account does not exist. No changes made.';
return c.json(new Message(message));
}
catch(e){
console.error(e);
throw new DatabaseError();
}
})
export default app