diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 5907d41..922d49f 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -6,24 +6,24 @@ export const attraction = sqliteTable('attraction', { id: integer().primaryKey({ autoIncrement: true }), name: text().notNull(), apiCode: text('api_code').notNull().unique(), - themeparkId: integer('themepark_id').notNull().references(() => themepark.id, {onDelete: 'cascade'}) + themeparkId: integer('themepark_id').notNull().references(() => themepark.id) }, (t) => [ unique().on(t.apiCode, t.themeparkId) ]) export const attractionNotification = sqliteTable('attraction_notification', { id: integer().primaryKey({ autoIncrement: true}), - userId: integer('user_id').notNull().references(() => user.id, {onDelete: 'cascade'}), - attractionId: integer('attraction_id').notNull().references(() => attraction.id, {onDelete: 'cascade'}), - notificationMethodId: integer('notification_method_id').notNull().references(() => notificationMethod.id, {onDelete: 'cascade'}) + userId: integer('user_id').notNull().references(() => user.id), + attractionId: integer('attraction_id').notNull().references(() => attraction.id), + notificationMethodId: integer('notification_method_id').notNull().references(() => notificationMethod.id) }, (t) => [ unique().on(t.userId, t.attractionId, t.notificationMethodId) ]) export const logbook = sqliteTable('logbook', { id: integer().primaryKey({ autoIncrement: true }), - userId: integer('user_id').notNull().references(() => user.id, {onDelete: 'cascade'}), - attractionId: integer('attraction_id').notNull().references(() => attraction.id, {onDelete: 'cascade'}), + userId: integer('user_id').notNull().references(() => user.id), + attractionId: integer('attraction_id').notNull().references(() => attraction.id), timestamp: integer().notNull(), // unix timecode expectedWaittime: integer(), realWaittime: integer() @@ -35,8 +35,8 @@ export const notificationMethod = sqliteTable('notification_method', { id: integer().primaryKey({ autoIncrement: true }), webhookUrl: text('webhook_url').notNull(), shownName: text().notNull(), - userId: integer('user_id').notNull().references(() => user.id, {onDelete: 'cascade'}), - notificationProviderId: integer('notification_provider_id').notNull().references(() => notificationProvider.id, {onDelete: 'cascade'}), + userId: integer('user_id').notNull().references(() => user.id), + notificationProviderId: integer('notification_provider_id').notNull().references(() => notificationProvider.id), }, (t) => [ unique().on(t.webhookUrl, t.userId, t.notificationProviderId) ]) diff --git a/api/src/errors/http-error.ts b/api/src/errors/http-error.ts index 17d20be..ed9eea3 100644 --- a/api/src/errors/http-error.ts +++ b/api/src/errors/http-error.ts @@ -28,7 +28,7 @@ export class InvalidParameter extends HTTPException{ super(400, { message: paramName ? `Provided parameter '${paramName}' is invalid.` - : 'Provided invalid request parameter(s) or some required parameter is missing.' + : 'Provided invalid parameter.' }) } } diff --git a/api/src/index.ts b/api/src/index.ts index 5d5a858..b284adf 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -6,7 +6,6 @@ import attraction from './routes/attraction' import notification from './routes/notification-method' import logbook from './routes/logbook' import themepark from './routes/themepark' -import user from './routes/user' import cronRouter from './jobs/cron' // create app @@ -38,7 +37,6 @@ app.route('/attraction', attraction) app.route('/notification-method', notification) app.route('/logbook', logbook) app.route('/themepark', themepark) -app.route('/user', user) export default { fetch: app.fetch, scheduled: cronRouter, diff --git a/api/src/lib/cache.ts b/api/src/lib/cache.ts index b631337..33124ce 100644 --- a/api/src/lib/cache.ts +++ b/api/src/lib/cache.ts @@ -1,17 +1,9 @@ import { cache } from 'hono/cache' /** - * Cache unit to use for multiple endpoints as needed (TTL: 86400s) + * Cache unit to use for multiple endpoints as needed */ export const responseCache = cache({ cacheName: 'themepark-assistant', cacheControl: 'max-age=86400' -}); - -/** - * Cache for dynamic data (TTL: 30s) - */ -export const dynamicCache = cache({ - cacheName: 'themepark-assistant-dynamic', - cacheControl: 'max-age=30s' }); \ No newline at end of file diff --git a/api/src/lib/http-z-validator.ts b/api/src/lib/http-z-validator.ts index ee9a787..3308ffe 100644 --- a/api/src/lib/http-z-validator.ts +++ b/api/src/lib/http-z-validator.ts @@ -8,23 +8,8 @@ import { InvalidParameter } from '../errors' * @param schema Zod Validation scheme (docs: https://zod.dev/api) * @returns zValidator for running the validation */ -export function httpZValidator(type: 'query' | 'json' | 'param' = 'query', schema: T){ +export default function httpZValidator(type: 'query' | 'json' | 'param' = 'query', schema: T){ return zValidator(type, schema, (result, c) => { 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() -})); \ No newline at end of file +} \ No newline at end of file diff --git a/api/src/routes/attraction.ts b/api/src/routes/attraction.ts index 8e95071..a9f2a02 100644 --- a/api/src/routes/attraction.ts +++ b/api/src/routes/attraction.ts @@ -1,4 +1,4 @@ -import { Hono } from 'hono' +import { Hono, Context } from 'hono' import { getDbContext } from '../db/client' import { attractionNotification } from '../db/schema' import { and, eq } from 'drizzle-orm' @@ -6,14 +6,19 @@ import { DatabaseError, InvalidParameter, MissingParameter } from '../errors' import { getUser } from '../lib/user-auth' import { Message } from '../types/response' 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() - - - +/** + * 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 @@ -32,12 +37,13 @@ function hasMessageCause(e: unknown): e is Error & { cause: { message: string }} /** * Subscribe to waittime notifications from a specified attraction */ -app.post('/:id/subscribe', idValidator, notificationMethodIdValidator, async (c) => { - const attractionId = c.req.valid('param').id; +app.post('/:id/subscribe', async (c) => { + const attractionId = parseInt(c.req.param('id')); const db = getDbContext(c) const user = await getUser(c); - const notificationMethodId = c.req.valid('query').notificationMethodId; + const notificationMethodId = getNotificationMethodId(c); + if(!notificationMethodId) throw new MissingParameter('notificationMethodId'); const method = await getNotificationMethodOwner(db, notificationMethodId, user.id); @@ -67,12 +73,12 @@ app.post('/:id/subscribe', idValidator, notificationMethodIdValidator, async (c) /** * Unsubscribe to waittime notifications from a specified attraction */ -app.post('/:id/unsubscribe', idValidator, notificationMethodIdValidator, async (c) => { - const attractionId = c.req.valid('param').id; +app.post('/:id/unsubscribe', async (c) => { + const attractionId = parseInt(c.req.param('id')); const db = getDbContext(c) const user = await getUser(c); - const notificationMethodId = c.req.valid('query').notificationMethodId; + const notificationMethodId = getNotificationMethodId(c); const methodOwner = notificationMethodId ? await getNotificationMethodOwner(db, notificationMethodId, user.id): false; const queryConditions = [ diff --git a/api/src/routes/logbook.ts b/api/src/routes/logbook.ts index 6edf27c..fa36f1e 100644 --- a/api/src/routes/logbook.ts +++ b/api/src/routes/logbook.ts @@ -1,150 +1,13 @@ 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() -/** - * Lists all logbook entries of the logged in user - */ -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(); - } +app.get('/list', (c) => { + return c.json( + { + message: 'List all logbook entries' + } + ) }) export default app \ No newline at end of file diff --git a/api/src/routes/notification-method.ts b/api/src/routes/notification-method.ts index a735af3..7dde507 100644 --- a/api/src/routes/notification-method.ts +++ b/api/src/routes/notification-method.ts @@ -5,9 +5,6 @@ import { eq, and } from 'drizzle-orm' import { getUser } from '../lib/user-auth' import { DatabaseError, InvalidParameter, MissingParameter } from '../errors' 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() @@ -54,7 +51,7 @@ app.get('/list', async (c) => { }) /** Lists all available notification providers */ -app.get('/list-providers', responseCache, async (c) => { +app.get('/list-providers', async (c) => { const db = getDbContext(c); try{ @@ -71,24 +68,21 @@ app.get('/list-providers', responseCache, async (c) => { }) /** Creates a new notification method from url, name & provider */ -app.post('/add-method', httpZValidator('query', z.strictObject({ - url: z.string(), - name: z.string(), - provider: z.string() -})), -async (c) => { +app.post('/add-method', async (c) => { const db = getDbContext(c); const user = await getUser(c); - const params = c.req.valid('query'); + const { url, name, provider } = c.req.query(); - const providerId = await getProviderId(db, params.provider); + if(!url || !name || !provider) throw new MissingParameter(); + + const providerId = await getProviderId(db, provider); if(!providerId) throw new InvalidParameter('provider'); try{ const newMethod = await db.insert(notificationMethod).values({ - webhookUrl: params.url, - shownName: params.name, + webhookUrl: url, + shownName: name, userId: user.id, notificationProviderId: providerId }).returning().onConflictDoNothing().get(); @@ -109,10 +103,12 @@ async (c) => { }) /** Removes a existing notification method by id (has to be owned by the current user) */ -app.delete('/remove-method/:id', idValidator, async (c) => { +app.delete('/remove-method/:id', async (c) => { const db = getDbContext(c); const user = await getUser(c); - const methodId = c.req.valid('param').id; + const methodId = parseInt(c.req.param('id')); + + if(!methodId) throw new InvalidParameter('id'); try{ const res = await db.delete(notificationMethod).where( diff --git a/api/src/routes/themepark.ts b/api/src/routes/themepark.ts index ae2e7df..7bd89b9 100644 --- a/api/src/routes/themepark.ts +++ b/api/src/routes/themepark.ts @@ -4,7 +4,6 @@ import { themepark, attraction } from '../db/schema' import { responseCache } from '../lib/cache' import { eq } from 'drizzle-orm' import { DatabaseError } from '../errors' -import { idValidator } from '../lib/http-z-validator' const app = new Hono() @@ -31,8 +30,8 @@ app.get('/list', responseCache, async (c) => { /** * Lists all attractions from a themepark with their id & name */ -app.get('/list/:id/attraction', responseCache, idValidator, async (c) => { - const parkId = c.req.valid('param').id; +app.get('/list/:id/attraction', responseCache, async (c) => { + const parkId = parseInt(c.req.param('id')); const db = getDbContext(c) try{ diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts deleted file mode 100644 index a6975a5..0000000 --- a/api/src/routes/user.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 \ No newline at end of file