diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 922d49f..5907d41 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) + themeparkId: integer('themepark_id').notNull().references(() => themepark.id, {onDelete: 'cascade'}) }, (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), - attractionId: integer('attraction_id').notNull().references(() => attraction.id), - notificationMethodId: integer('notification_method_id').notNull().references(() => notificationMethod.id) + 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'}) }, (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), - attractionId: integer('attraction_id').notNull().references(() => attraction.id), + userId: integer('user_id').notNull().references(() => user.id, {onDelete: 'cascade'}), + attractionId: integer('attraction_id').notNull().references(() => attraction.id, {onDelete: 'cascade'}), 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), - notificationProviderId: integer('notification_provider_id').notNull().references(() => notificationProvider.id), + userId: integer('user_id').notNull().references(() => user.id, {onDelete: 'cascade'}), + notificationProviderId: integer('notification_provider_id').notNull().references(() => notificationProvider.id, {onDelete: 'cascade'}), }, (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 ed9eea3..17d20be 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 parameter.' + : 'Provided invalid request parameter(s) or some required parameter is missing.' }) } } diff --git a/api/src/index.ts b/api/src/index.ts index b284adf..5d5a858 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -6,6 +6,7 @@ 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 @@ -37,6 +38,7 @@ 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 33124ce..b631337 100644 --- a/api/src/lib/cache.ts +++ b/api/src/lib/cache.ts @@ -1,9 +1,17 @@ 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({ 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 3308ffe..ee9a787 100644 --- a/api/src/lib/http-z-validator.ts +++ b/api/src/lib/http-z-validator.ts @@ -8,8 +8,23 @@ import { InvalidParameter } from '../errors' * @param schema Zod Validation scheme (docs: https://zod.dev/api) * @returns zValidator for running the validation */ -export default function httpZValidator(type: 'query' | 'json' | 'param' = 'query', schema: T){ +export function httpZValidator(type: 'query' | 'json' | 'param' = 'query', schema: T){ return zValidator(type, schema, (result, c) => { if(!result.success) throw new InvalidParameter(); }) -} \ No newline at end of file +} + +// 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 diff --git a/api/src/routes/attraction.ts b/api/src/routes/attraction.ts index a9f2a02..8e95071 100644 --- a/api/src/routes/attraction.ts +++ b/api/src/routes/attraction.ts @@ -1,4 +1,4 @@ -import { Hono, Context } from 'hono' +import { Hono } from 'hono' import { getDbContext } from '../db/client' import { attractionNotification } from '../db/schema' import { and, eq } from 'drizzle-orm' @@ -6,19 +6,14 @@ 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 @@ -37,13 +32,12 @@ function hasMessageCause(e: unknown): e is Error & { cause: { message: string }} /** * Subscribe to waittime notifications from a specified attraction */ -app.post('/:id/subscribe', async (c) => { - const attractionId = parseInt(c.req.param('id')); +app.post('/:id/subscribe', idValidator, notificationMethodIdValidator, async (c) => { + const attractionId = c.req.valid('param').id; const db = getDbContext(c) const user = await getUser(c); - const notificationMethodId = getNotificationMethodId(c); - if(!notificationMethodId) throw new MissingParameter('notificationMethodId'); + const notificationMethodId = c.req.valid('query').notificationMethodId; 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 */ -app.post('/:id/unsubscribe', async (c) => { - const attractionId = parseInt(c.req.param('id')); +app.post('/:id/unsubscribe', idValidator, notificationMethodIdValidator, async (c) => { + const attractionId = c.req.valid('param').id; const db = getDbContext(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 queryConditions = [ diff --git a/api/src/routes/logbook.ts b/api/src/routes/logbook.ts index fa36f1e..6edf27c 100644 --- a/api/src/routes/logbook.ts +++ b/api/src/routes/logbook.ts @@ -1,13 +1,150 @@ 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() -app.get('/list', (c) => { - return c.json( - { - message: 'List all logbook entries' - } - ) +/** + * 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(); + } }) 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 7dde507..a735af3 100644 --- a/api/src/routes/notification-method.ts +++ b/api/src/routes/notification-method.ts @@ -5,6 +5,9 @@ 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() @@ -51,7 +54,7 @@ app.get('/list', async (c) => { }) /** Lists all available notification providers */ -app.get('/list-providers', async (c) => { +app.get('/list-providers', responseCache, async (c) => { const db = getDbContext(c); try{ @@ -68,21 +71,24 @@ app.get('/list-providers', async (c) => { }) /** 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 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, provider); + const providerId = await getProviderId(db, params.provider); if(!providerId) throw new InvalidParameter('provider'); try{ const newMethod = await db.insert(notificationMethod).values({ - webhookUrl: url, - shownName: name, + webhookUrl: params.url, + shownName: params.name, userId: user.id, notificationProviderId: providerId }).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) */ -app.delete('/remove-method/:id', async (c) => { +app.delete('/remove-method/:id', idValidator, async (c) => { const db = getDbContext(c); const user = await getUser(c); - const methodId = parseInt(c.req.param('id')); - - if(!methodId) throw new InvalidParameter('id'); + const methodId = c.req.valid('param').id; try{ const res = await db.delete(notificationMethod).where( diff --git a/api/src/routes/themepark.ts b/api/src/routes/themepark.ts index 7bd89b9..ae2e7df 100644 --- a/api/src/routes/themepark.ts +++ b/api/src/routes/themepark.ts @@ -4,6 +4,7 @@ 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() @@ -30,8 +31,8 @@ app.get('/list', responseCache, async (c) => { /** * Lists all attractions from a themepark with their id & name */ -app.get('/list/:id/attraction', responseCache, async (c) => { - const parkId = parseInt(c.req.param('id')); +app.get('/list/:id/attraction', responseCache, idValidator, async (c) => { + const parkId = c.req.valid('param').id; const db = getDbContext(c) try{ diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts new file mode 100644 index 0000000..a6975a5 --- /dev/null +++ b/api/src/routes/user.ts @@ -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 \ No newline at end of file