Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 49 additions & 31 deletions app/controllers/meals_controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import vine from "@vinejs/vine";
import { DateTime } from "luxon";
import { z } from "zod";

import type { HttpContext } from "@adonisjs/core/http";
import logger from "@adonisjs/core/services/logger";
Expand All @@ -9,19 +9,36 @@ import HashesMeal from "#models/hashes_meal";
import Meal from "#models/meal";
import WebsiteHash from "#models/website_hash";

const firstHashWithMealsRawSchema = z.object({
rows: z
.array(
z.object({
hash: z.string(),
}),
)
.nonempty(),
});

const distinctMealIdsSchema = z.array(
z.object({
meal_id: z.coerce.number(),
const firstHashWithMealsRawValidator = vine.compile(
vine.object({
rows: vine
.array(
vine.object({
hash: vine.string(),
}),
)
.minLength(1),
}),
);

const distinctMealIdsValidator = vine.compile(
vine.array(
vine.object({
meal_id: vine.number(),
}),
),
);

const paginationValidator = vine.compile(
vine.object({
page: vine.number().min(1).optional(),
limit: vine.number().min(1).optional(),
}),
);

const recentSearchValidator = vine.compile(
vine.object({
search: vine.string().trim().optional(),
}),
);

Expand Down Expand Up @@ -60,27 +77,24 @@ export default class MealsController {
logger.debug(
"No meals found in the latest hash - fetching the previous one",
);
const firstHashWithMealsRaw = firstHashWithMealsRawSchema.parse(
await db
.rawQuery(
`
const firstHashWithMealsRaw = await firstHashWithMealsRawValidator.validate(
await db.rawQuery(
`
SELECT website_hashes.hash FROM public.website_hashes LEFT JOIN public.hashes_meals ON website_hashes.hash = hashes_meals.hash_fk
GROUP BY website_hashes.hash
HAVING COUNT(hashes_meals.*) != 0
ORDER BY website_hashes.updated_at DESC
LIMIT 1
`,
)
.addErrorContext(
"Failed to fetch the first hash with meals from the database",
),
).rows[0].hash;
),
);

const firstHashWithMeals = await WebsiteHash.query()
.where("hash", firstHashWithMealsRaw)
.where("hash", firstHashWithMealsRaw.rows[0].hash)
.firstOrFail()
.addErrorContext(
() =>
`Failed to fetch the website hash record for hash ${firstHashWithMealsRaw}`,
`Failed to fetch the website hash record for hash ${firstHashWithMealsRaw.rows[0].hash}`,
);
todayMeals = await getMealsByHash(firstHashWithMeals.hash);
logger.debug(`fetched ${todayMeals.length} meals from the database}`);
Expand All @@ -101,8 +115,10 @@ export default class MealsController {
* @responseBody 500 - {"message":"string","error":"string"}
*/
async index({ request, response }: HttpContext) {
const page = request.input("page", 1) as number;
const limit = request.input("limit", 10) as number;
const payload = await request.validateUsing(paginationValidator);

const page = payload.page ?? 1;
const limit = payload.limit ?? 10;

const hashes = await HashesMeal.query()
.orderBy("createdAt", "desc")
Expand Down Expand Up @@ -140,7 +156,8 @@ export default class MealsController {
* @responseBody 500 - {"message":"string","error":"string"}
*/
async recent({ request, response }: HttpContext) {
const rawSearch = (request.input("search", "") as string).trim();
const payload = await request.validateUsing(recentSearchValidator);
const rawSearch = payload.search ?? "";
const sevenDaysAgo = DateTime.now().minus({ days: 7 }).toJSDate();

const mealIdRows = await db
Expand All @@ -156,10 +173,9 @@ export default class MealsController {
void query.whereILike("meals.name", `%${rawSearch}%`);
})
.select("hashes_meals.meal_id as meal_id")
.distinct()
.addErrorContext("Failed to fetch meal IDs from last 7 days");
.distinct();

const parsedMealIds = distinctMealIdsSchema.parse(mealIdRows);
const parsedMealIds = await distinctMealIdsValidator.validate(mealIdRows);

const mealIds = parsedMealIds.map((row) => row.meal_id);

Expand All @@ -170,6 +186,7 @@ export default class MealsController {
const meals = await Meal.query()
.whereIn("id", mealIds)
.orderBy("name", "asc")
.exec()
.addErrorContext(
() =>
`Failed to fetch meals from the last 7 days with search term '${rawSearch}'`,
Expand All @@ -185,6 +202,7 @@ async function getMealsByHash(hash: string) {
return await HashesMeal.query()
.where("hashFk", hash)
.preload("meal")
.exec()
.addErrorContext(() => `Failed to fetch meals for hash ${hash}`);
}

Expand Down
30 changes: 12 additions & 18 deletions app/controllers/registration_tokens_controller.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import vine from "@vinejs/vine";
import { DateTime } from "luxon";
import { z, null as zodNull } from "zod";

import type { HttpContext } from "@adonisjs/core/http";

import Device, { getTokenExpirationTime } from "#models/device";

const RegistrationTokenPayload = z.object({
deviceKey: z.string().min(1),
registrationToken: z.string().min(1).or(zodNull()),
});
const RegistrationTokenPayload = vine.compile(
vine.object({
deviceKey: vine.string().minLength(1),
registrationToken: vine.string().minLength(1).optional().nullable(),
}),
);

interface RegistrationTokenInput {
deviceKey: unknown;
registrationToken: unknown;
}

const deviceKeySchema = z.string().min(1, "deviceKey param is required");
const deviceKeyValidator = vine.compile(vine.string().minLength(1));

export default class RegistrationTokensController {
/**
Expand All @@ -25,7 +22,9 @@ export default class RegistrationTokensController {
* @responseBody 400 - {"error":"string"}
*/
async hasToken({ request, response }: HttpContext) {
const deviceKey = deviceKeySchema.parse(request.param("deviceKey"));
const deviceKey = await deviceKeyValidator.validate(
request.param("deviceKey"),
);
const device = await Device.findByOrFail(
"deviceKey",
deviceKey,
Expand Down Expand Up @@ -58,12 +57,7 @@ export default class RegistrationTokensController {
* @responseBody 500 - {"message":"string","error":"string"}
*/
async updateOrCreate({ request, response }: HttpContext) {
const raw = request.body() as RegistrationTokenInput;

const parsed = RegistrationTokenPayload.parse({
deviceKey: raw.deviceKey,
registrationToken: raw.registrationToken,
});
const parsed = await request.validateUsing(RegistrationTokenPayload);

const { deviceKey, registrationToken } = parsed;

Expand Down
1 change: 1 addition & 0 deletions app/controllers/sks_users_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export default class SksUsersController {
const todayData = await SksUser.query()
.whereBetween("externalTimestamp", [todayStart, todayEnd])
.orderBy("externalTimestamp", "asc")
.exec()
.addErrorContext(
() => `Failed to fetch users for ${todayStart} to ${todayEnd}`,
);
Expand Down
63 changes: 24 additions & 39 deletions app/controllers/subscriptions_controller.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
import { z } from "zod";
import vine from "@vinejs/vine";

import type { HttpContext } from "@adonisjs/core/http";
import db from "@adonisjs/lucid/services/db";

import Device from "#models/device";

const SubscriptionToggleSchema = z.object({
deviceKey: z.string().min(1),
mealId: z.number(),
subscribe: z.boolean(),
});

interface RawSubscriptionToggleInput {
deviceKey: unknown;
mealId: unknown;
subscribe: unknown;
}
const SubscriptionToggleValidator = vine.compile(
vine.object({
deviceKey: vine.string().minLength(1),
mealId: vine.number(),
subscribe: vine.boolean(),
}),
);

interface PgResult {
rowCount: number;
}

const deviceKeySchema = z.string().min(1, "deviceKey param is required");
const deviceKeyValidator = vine.compile(vine.string().minLength(1));

export default class SubscriptionsController {
/**
Expand All @@ -34,38 +30,25 @@ export default class SubscriptionsController {
* @responseBody 500 - {"message":"string","error":"string"}
*/
async toggle({ request, response }: HttpContext) {
const raw = request.body() as RawSubscriptionToggleInput;
const parsed = SubscriptionToggleSchema.parse({
deviceKey: raw.deviceKey,
mealId: raw.mealId,
subscribe: raw.subscribe,
});
const { deviceKey, mealId, subscribe } = parsed;
const payload = await request.validateUsing(SubscriptionToggleValidator);
const { deviceKey, mealId, subscribe } = payload;
if (subscribe) {
const res = (await db
.rawQuery(
"INSERT INTO subscriptions (device_key, meal_id, created_at) VALUES (?, ?, NOW()) ON CONFLICT DO NOTHING",
[deviceKey, mealId],
{ mode: "write" },
)
.addErrorContext(
() => `Failed to register device for meal notifications`,
)) as PgResult;
const res = await db.rawQuery<PgResult>(
"INSERT INTO subscriptions (device_key, meal_id, created_at) VALUES (?, ?, NOW()) ON CONFLICT DO NOTHING",
[deviceKey, mealId],
{ mode: "write" },
);
if (res.rowCount === 0) {
return response.ok({ message: "Already subscribed" });
} else {
return response.ok({ message: "Subscribed" });
}
} else {
const res = (await db
.rawQuery(
"DELETE FROM subscriptions WHERE device_key = ? AND meal_id = ?",
[deviceKey, mealId],
{ mode: "write" },
)
.addErrorContext(
`Failed to unregister device from meal notifications`,
)) as PgResult;
const res = await db.rawQuery<PgResult>(
"DELETE FROM subscriptions WHERE device_key = ? AND meal_id = ?",
[deviceKey, mealId],
{ mode: "write" },
);
if (res.rowCount === 0) {
return response.ok({ message: "Was not subscribed" });
} else {
Expand All @@ -79,7 +62,9 @@ export default class SubscriptionsController {
* @summary Get meals the device is subscribed to
*/
async listForDevice({ request, response }: HttpContext) {
const deviceKey = deviceKeySchema.parse(request.param("deviceKey"));
const deviceKey = await deviceKeyValidator.validate(
request.param("deviceKey"),
);

const device = await Device.query()
.where("deviceKey", deviceKey)
Expand Down
6 changes: 3 additions & 3 deletions app/exceptions/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
prepareReportForLogging,
serializeErrorReport,
} from "@solvro/error-handling/reporting";
import { z } from "zod";
import { errors as vineErrors } from "@vinejs/vine";

import { ExceptionHandler } from "@adonisjs/core/http";
import type { HttpContext } from "@adonisjs/core/http";
Expand Down Expand Up @@ -37,8 +37,8 @@ export default class HttpExceptionHandler extends ExceptionHandler {
error: unknown,
ctx: HttpContext & ExceptionHandlerContextExtras,
) {
if (error instanceof z.ZodError) {
return ctx.response.badRequest({
if (error instanceof vineErrors.E_VALIDATION_ERROR) {
return ctx.response.status(422).send({
message: "Invalid input",
error: error.message,
});
Expand Down
Loading